"""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", ""), )