392 lines
15 KiB
Python
392 lines
15 KiB
Python
"""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", ""),
|
||
)
|