Files
2026Technology-Competition/src/genesis/server/store.py
T

392 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Web 服务化:SQLite 会话存储(S2)。
满足参赛成果物 03「数据存储」:会话 / 状态 / 文件登记 / 结果路径持久化到 SQLite。
零外部依赖(标准库 sqlite3),db 路径可注入(测试用 tmp_path)。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
class SessionNotFoundError(Exception):
"""会话不存在(对应 api-design §7 404)。"""
class ProjectConfigError(Exception):
"""项目配置非法(路径不存在/类型不符/越界)。"""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@dataclass
class SessionRecord:
session_id: str
user_id: str
name: str = "新会话" # 会话显示名(默认「新会话」,上传要件定义后自动取文件名)
project: str = "" # 绑定的项目名(空=无项目)
status: str = "uploading"
files: dict = field(default_factory=dict) # file_type -> {file_id,name,size,path}
structured_summary: str = "" # 解析结果摘要(JSON 字符串)
impact_summary: str = "" # 影响调查摘要
qa_summary: str = "" # QA 报告 JSON
result_path: str = "" # 概要设计书 docx 路径
impact_report_path: str = ""
qa_report_path: str = ""
output_language: str = "auto"
pending_intent: str = "" # 等待确认后继续的意图(如 "generate"
created_at: str = ""
updated_at: str = ""
@property
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"user_id": self.user_id,
"name": self.name,
"project": self.project,
"status": self.status,
"files": self.files,
"structured_summary": self.structured_summary,
"impact_summary": self.impact_summary,
"qa_summary": self.qa_summary,
"result_path": self.result_path,
"impact_report_path": self.impact_report_path,
"qa_report_path": self.qa_report_path,
"output_language": self.output_language,
"pending_intent": self.pending_intent,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
class SessionStore:
"""SQLite 持久化的会话存储。
表结构:sessions(session_id TEXT PK, user_id, data TEXT) —— data 为整条
SessionRecord 的 JSON(简单可靠;会话量为小规模,无需列级查询)。
"""
def __init__(self, db_path: str = "data/server/sessions.db") -> None:
self._db = Path(db_path)
self._db.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self._db))
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._conn() as c:
c.execute(
"CREATE TABLE IF NOT EXISTS sessions ("
" session_id TEXT PRIMARY KEY,"
" user_id TEXT NOT NULL,"
" data TEXT NOT NULL)"
)
c.execute(
"CREATE TABLE IF NOT EXISTS chat_messages ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" session_id TEXT NOT NULL,"
" role TEXT NOT NULL,"
" content TEXT NOT NULL,"
" action TEXT,"
" created_at TEXT NOT NULL)"
)
c.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_session ON chat_messages(session_id)"
)
# ---------- 聊天消息 ----------
def add_message(self, session_id: str, role: str, content: str, action: str | None = None) -> dict:
msg = {"role": role, "content": content, "action": action, "created_at": _now()}
with self._conn() as c:
c.execute(
"INSERT INTO chat_messages (session_id, role, content, action, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(session_id, role, content, action, msg["created_at"]),
)
return msg
def list_messages(self, session_id: str) -> list[dict]:
with self._conn() as c:
rows = c.execute(
"SELECT role, content, action, created_at FROM chat_messages"
" WHERE session_id = ? ORDER BY id",
(session_id,),
).fetchall()
return [dict(r) for r in rows]
def create_session(self, user_id: str, name: str | None = None, project: str | None = None) -> SessionRecord:
rec = SessionRecord(
session_id=uuid.uuid4().hex[:12],
user_id=user_id,
name=name or "新会话",
project=project or "",
created_at=_now(),
updated_at=_now(),
)
with self._conn() as c:
c.execute(
"INSERT INTO sessions (session_id, user_id, data) VALUES (?, ?, ?)",
(rec.session_id, rec.user_id, json.dumps(rec.to_dict, ensure_ascii=False)),
)
return rec
def get_session(self, session_id: str) -> SessionRecord:
with self._conn() as c:
row = c.execute(
"SELECT data FROM sessions WHERE session_id = ?", (session_id,)
).fetchone()
if row is None:
raise SessionNotFoundError(f"会话不存在: {session_id}")
return self._from_dict(json.loads(row["data"]))
def list_sessions(self, user_id: str, project: str | None = None) -> list[SessionRecord]:
if project is None:
with self._conn() as c:
rows = c.execute(
"SELECT data FROM sessions WHERE user_id = ?",
(user_id,),
).fetchall()
recs = [self._from_dict(json.loads(r["data"])) for r in rows]
else:
try:
with self._conn() as c:
rows = c.execute(
"SELECT data FROM sessions WHERE user_id = ?"
" AND json_extract(data, '$.project') = ?",
(user_id, project),
).fetchall()
recs = [self._from_dict(json.loads(r["data"])) for r in rows]
except Exception:
# json_extract 不可用(旧 SQLite)→ 全量取回后 Python 过滤兜底
with self._conn() as c:
rows = c.execute(
"SELECT data FROM sessions WHERE user_id = ?", (user_id,)
).fetchall()
recs = [self._from_dict(json.loads(r["data"])) for r in rows]
recs = [r for r in recs if r.project == project]
# updated_at 在 JSON data 内,无法用 SQL 列排序 → 取回后按时间降序
recs.sort(key=lambda r: r.updated_at, reverse=True)
return recs
def update_status(self, session_id: str, status: str) -> SessionRecord:
rec = self.get_session(session_id)
rec.status = status
return self._persist(rec)
def update_session(self, session_id: str, **fields) -> SessionRecord:
"""按字段名更新会话(files/status/result_path 等任意 to_dict 键)。"""
rec = self.get_session(session_id)
allowed = set(SessionRecord.to_dict.fget.__annotations__) if hasattr(
SessionRecord.to_dict.fget, "__annotations__"
) else set(rec.to_dict.keys())
for k, v in fields.items():
if k in rec.to_dict:
setattr(rec, k, v)
return self._persist(rec)
def delete_session(self, session_id: str) -> bool:
with self._conn() as c:
cur = c.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
return cur.rowcount > 0
def _persist(self, rec: SessionRecord) -> SessionRecord:
rec.updated_at = _now()
with self._conn() as c:
c.execute(
"UPDATE sessions SET user_id = ?, data = ? WHERE session_id = ?",
(rec.user_id, json.dumps(rec.to_dict, ensure_ascii=False), rec.session_id),
)
return rec
@staticmethod
def _from_dict(d: dict) -> SessionRecord:
return SessionRecord(
session_id=d.get("session_id", ""),
user_id=d.get("user_id", ""),
name=d.get("name", "新会话"),
project=d.get("project", ""),
status=d.get("status", "uploading"),
files=d.get("files", {}),
structured_summary=d.get("structured_summary", ""),
impact_summary=d.get("impact_summary", ""),
qa_summary=d.get("qa_summary", ""),
result_path=d.get("result_path", ""),
impact_report_path=d.get("impact_report_path", ""),
qa_report_path=d.get("qa_report_path", ""),
output_language=d.get("output_language", "auto"),
pending_intent=d.get("pending_intent", ""),
created_at=d.get("created_at", ""),
updated_at=d.get("updated_at", ""),
)
@dataclass
class ProjectConfig:
"""项目级配置(前端设置、服务端持久化)。
模板/做成说明书为单文件;rules/design_docs 为目录(枚举其中 .docx);
existing_system_code_dir 为代码库目录(直接交给 CodeParser)。
"""
name: str
display_name: str = ""
template: str = ""
write_instruction: str = ""
rules: list = field(default_factory=list)
existing_system_code_dir: str = ""
design_docs_dir: str = ""
@property
def to_dict(self) -> dict:
return {
"name": self.name,
"display_name": self.display_name,
"template": self.template,
"write_instruction": self.write_instruction,
"rules": list(self.rules),
"existing_system_code_dir": self.existing_system_code_dir,
"design_docs_dir": self.design_docs_dir,
}
def _validate_project_paths(
template: str, write_instruction: str, rules: list, existing_system_code_dir: str, design_docs_dir: str
) -> tuple[list, list]:
"""校验项目配置路径。
Returns:
(rules_docs, design_docs):枚举后的 .docx 路径列表
Raises:
ProjectConfigError: 路径不存在或类型不符
"""
def _docx(p: str, label: str) -> str:
pp = Path(p)
if not pp.exists():
raise ProjectConfigError(f"{label} 路径不存在: {p}")
if pp.suffix.lower() != ".docx":
raise ProjectConfigError(f"{label} 须为 .docx 文件: {p}")
return str(pp)
def _dir(p: str, label: str) -> str:
pp = Path(p)
if not pp.exists():
raise ProjectConfigError(f"{label} 路径不存在: {p}")
if not pp.is_dir():
raise ProjectConfigError(f"{label} 须为目录: {p}")
return str(pp)
if template:
_docx(template, "模板")
if write_instruction:
_docx(write_instruction, "做成说明书")
if existing_system_code_dir:
_dir(existing_system_code_dir, "既有系统代码库")
rules_docs = []
for p in (rules or []):
pp = Path(p)
if pp.is_dir():
rules_docs.extend(str(x) for x in sorted(pp.rglob("*.docx")))
else:
rules_docs.append(_docx(p, "记入/图表规则"))
design_docs = []
if design_docs_dir:
ddir = _dir(design_docs_dir, "既有系统设计文档目录")
design_docs = [str(p) for p in sorted(Path(ddir).rglob("*.docx"))]
return rules_docs, design_docs
class ProjectsStore:
"""项目配置存储(复用 SessionStore 同一 SQLite 文件)。"""
def __init__(self, db_path: str = "data/server/sessions.db") -> None:
self._db = Path(db_path)
self._db.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self._db))
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._conn() as c:
c.execute(
"CREATE TABLE IF NOT EXISTS projects ("
" name TEXT PRIMARY KEY,"
" display_name TEXT NOT NULL,"
" data TEXT NOT NULL)"
)
def upsert(
self,
name: str,
display_name: str,
template: str,
write_instruction: str,
rules: list,
existing_system_code_dir: str,
design_docs_dir: str,
) -> ProjectConfig:
if not name:
raise ProjectConfigError("项目名称不能为空")
# 校验路径(同时枚举 rules / design_docs 目录)
rules_docs, design_docs = _validate_project_paths(
template, write_instruction, rules, existing_system_code_dir, design_docs_dir
)
cfg = ProjectConfig(
name=name,
display_name=display_name or name,
template=template,
write_instruction=write_instruction,
rules=rules_docs,
existing_system_code_dir=existing_system_code_dir,
design_docs_dir=design_docs_dir,
)
with self._conn() as c:
c.execute(
"INSERT INTO projects (name, display_name, data) VALUES (?, ?, ?)"
" ON CONFLICT(name) DO UPDATE SET display_name=excluded.display_name, data=excluded.data",
(cfg.name, cfg.display_name, json.dumps(cfg.to_dict, ensure_ascii=False)),
)
return cfg
def get(self, name: str) -> ProjectConfig | None:
with self._conn() as c:
row = c.execute("SELECT data FROM projects WHERE name = ?", (name,)).fetchone()
if row is None:
return None
return self._from_dict(json.loads(row["data"]))
def list(self) -> list[ProjectConfig]:
with self._conn() as c:
rows = c.execute("SELECT data FROM projects ORDER BY name").fetchall()
return [self._from_dict(json.loads(r["data"])) for r in rows]
def delete(self, name: str) -> bool:
with self._conn() as c:
cur = c.execute("DELETE FROM projects WHERE name = ?", (name,))
return cur.rowcount > 0
@staticmethod
def _from_dict(d: dict) -> ProjectConfig:
return ProjectConfig(
name=d.get("name", ""),
display_name=d.get("display_name", ""),
template=d.get("template", ""),
write_instruction=d.get("write_instruction", ""),
rules=d.get("rules", []) or [],
existing_system_code_dir=d.get("existing_system_code_dir", ""),
design_docs_dir=d.get("design_docs_dir", ""),
)