diff --git a/.env.example b/.env.example index 9631206..9dd8d34 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,17 @@ POSTGRES_PASSWORD=change-me-db-password +BACKEND_PORT=6100 JWT_SECRET=change-me-jwt-secret INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_PASSWORD=admin INITIAL_ADMIN_DISPLAY_NAME=平台管理员 +INTEGRATION_API_KEY=replace-with-a-long-random-key AGENT_DEFAULT_MODEL=deepseek-v4-flash AGENT_DEFAULT_API_BASE_URL=https://api.deepseek.com/v1 +WIKI_AUTO_COMPILE_ENABLED=true +WIKI_WORKER_POLL_SECONDS=10 +WIKI_COMPILATION_STALE_MINUTES=60 +WIKI_MAX_DOCUMENT_BYTES=52428800 +WIKI_MAX_EXTRACTED_CHARS=500000 +WIKI_SOURCE_CHUNK_CHARS=12000 +WIKI_MAX_CHUNKS_PER_SOURCE=8 +WIKI_MAX_SOURCES_PER_RUN=20 diff --git a/.gitignore b/.gitignore index 8c04010..bda0ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ .env -backend/__pycache__/ -backend/app/__pycache__/ -backend/app/models/__pycache__/ -backend/app/routers/__pycache__/ -backend/app/schemas/__pycache__/ -backend/app/services/__pycache__/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.tmp/ frontend/node_modules/ frontend/dist/ backend/uploads/ diff --git a/README.md b/README.md index 2c55d69..17f0c1f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TinyPM -[![Version](https://img.shields.io/badge/version-1.1.0-blue)](./README.md) +[![Version](https://img.shields.io/badge/version-1.2.0-blue)](./README.md) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE) [![Open Source](https://img.shields.io/badge/Open%20Source-Yes-1f6feb)](./LICENSE) [![FastAPI](https://img.shields.io/badge/FastAPI-0.115-009688)](https://fastapi.tiangolo.com/) @@ -14,10 +14,10 @@ TinyPM 是一个面向小型研发、测试和交付团队的 **私有化 AI-nat 项目数据 -> MCP 工具 -> AI 项目助理 - -> 项目总审 / 计划审核 / 风险清单 / 周会摘要 + -> 项目总审 / 计划审核 / 风险清单 / 周会摘要 / 可信周报 ``` -当前版本:`1.1.0` +当前版本:`1.2.0` - English README: [README.en.md](./README.en.md) @@ -27,7 +27,7 @@ TinyPM 是一个面向小型研发、测试和交付团队的 **私有化 AI-nat ## 特性 - AI-native 项目管理 - - Agent 可通过 MCP 工具读取项目、计划、需求和问题 + - Agent 可通过 MCP 工具读取项目、计划、需求、问题和项目知识库 - 支持项目总审、当前计划审核、风险清单和周会摘要 - 适合小团队把项目数据交给 AI 复盘,而不是只靠口头描述 - 智能助理 @@ -40,6 +40,20 @@ TinyPM 是一个面向小型研发、测试和交付团队的 **私有化 AI-nat - Agent 通过 MCP 工具访问后端项目数据 - 支持查询项目、计划、需求、问题 - 支持通过 MCP 新增或更新项目计划 +- 外部集成 API + - 使用独立 API Key,供 Codex 或自动化脚本读取和写入项目业务数据 + - 支持幂等导入会议纪要、开发细节、聊天记录和 Markdown 文本 + - 机器接口不开放用户、角色和 Agent 模型密钥管理 +- 增量 LLM Wiki + - 项目知识条目、上传文档和绑定项目的成功聊天自动进入不可变来源收件箱 + - 独立 `wiki-worker` 持续把新来源编译进 topic、concept、synthesis 和 idea 页面 + - 页面保留来源观点、AI 推断、待验证问题和来源追溯,不把模型输出覆盖回原文 + - 可从项目详情导出 Obsidian 兼容 Markdown ZIP +- 证据驱动的项目周报 + - 按周期确定性采集计划、里程碑、需求、问题、知识资料和关键路径候选事实 + - 每条正式事实绑定来源证据、内容哈希和审核状态 + - AI 只读取已核验事实生成草稿,项目经理人工确认总体状态和正式终稿 + - 批准、归档后的事实与正式版本只读,保留 Prompt、模型、原稿和审核记录 - 用户与权限 - 默认初始化管理员账号 - 支持 `admin`、`manager`、`member` 三种角色 @@ -71,7 +85,7 @@ TinyPM 是一个面向小型研发、测试和交付团队的 **私有化 AI-nat 2. 登录默认管理员账号。 3. 创建一个项目,导入或录入项目计划。 4. 打开智能助理工作台,绑定当前项目。 -5. 让 Agent 生成项目总审、风险清单或周会摘要。 +5. 让 Agent 生成项目总审或风险清单;在项目详情“项目知识库”查看自动编译结果,再到“项目周报”完成可信周报闭环。 完整演示见 [docs/demo.md](./docs/demo.md),部署细节见 [docs/quickstart.md](./docs/quickstart.md)。 @@ -156,6 +170,7 @@ JWT_SECRET=your-jwt-secret INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_PASSWORD=change-this-password INITIAL_ADMIN_DISPLAY_NAME=平台管理员 +INTEGRATION_API_KEY=replace-with-a-long-random-key ``` ### 2. 使用 Docker 启动 @@ -170,6 +185,8 @@ docker compose up --build -d - 后端:`http://localhost:6100` - PostgreSQL:`localhost:6105` +如果主机的 `6100` 已被占用,可在 `.env` 中设置 `BACKEND_PORT=6110`;容器内端口和前端反向代理不受影响。 + ### 3. 默认管理员 如果你未修改 `.env`,默认初始化账号为: @@ -208,6 +225,27 @@ npm run dev - 支持 `deepseek-v4-flash` 与 `deepseek-v4-pro` - 大模型不可用时会展示具体原因,并在后端日志中记录每次失败 +### 外部集成与项目知识库 + +- Codex 等机器客户端使用独立 `INTEGRATION_API_KEY` 调用 `/api/integration/*` +- 项目知识库支持会议纪要、开发细节、聊天记录、文档文本和其他资料 +- 使用 `external_id` 可重复同步同一来源,不会产生重复记录 +- AI 助理通过 `search_project_knowledge` MCP 工具检索资料后分析项目 +- LLM Wiki 使用“机械入箱、增量编译”两阶段流程,编译页面和原始资料分表保存 +- 文档上传和项目聊天会唤醒后台编译;容器重启后 `wiki-worker` 会继续扫描持久 `inbox` +- 文本、DOCX、XLSX、PPTX 和带文本层 PDF 可直接提取;图片或扫描件会保留为待提取来源,不伪造正文 +- 项目详情“项目知识库”可查看来源状态、长期页面、编译失败原因并导出 Obsidian ZIP +- 接口清单和调用示例见 [外部集成 API](./docs/integration_api.md) +- 架构与边界见 [TinyPM LLM Wiki 架构](./docs/llm-wiki-architecture.md) + +### 项目周报 + +- 项目详情中的“项目周报”页签提供周期创建、候选采集、事实审核、证据查看、AI 草稿、人工终审和归档 +- `member` 可以查看周报并补充待确认候选;`manager` 和 `admin` 可以采集、核验、生成、批准和归档 +- Codex 可通过集成 API 批量导入候选事实,但导入项始终回到“待确认”,机器接口不能批准或归档 +- 项目历史基线在周期截止后补录时按业务日期进入候选并标记为“晚到资料”;截止时已存在但之后被改写的来源仍会排除,避免污染历史快照 +- 周报生成复用智能助理中配置的 OpenAI 兼容模型和 API Key,不在浏览器接口中返回已保存的密钥 + ### 项目公告 - 公告挂载在项目维度 @@ -240,6 +278,7 @@ npm run dev - 生产环境务必修改: - `POSTGRES_PASSWORD` - `JWT_SECRET` + - `INTEGRATION_API_KEY` - 初始化管理员密码 - 不要把真实账号、密码、客户敏感信息直接提交到仓库 - DeepSeek API Key 由用户在页面中填写并保存到本地部署数据库,仓库中不包含真实 API Key diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..2a9200b --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__ +*.py[cod] +.pytest_cache +tests +uploads +documents +.git +.gitignore diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..7d19ef3 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = %(here)s/alembic +prepend_sys_path = %(here)s +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/.gitignore b/backend/alembic/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/backend/alembic/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..4244272 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,64 @@ +"""Alembic 迁移运行环境。""" + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.engine import Connection + +from app import models as app_models +from app.database import Base, engine + + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """以离线模式生成迁移 SQL。""" + from app.config import settings + + context.configure( + url=settings.DATABASE_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def _run_with_connection(connection: Connection) -> None: + """使用指定数据库连接执行在线迁移。""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """以在线模式执行数据库迁移。""" + provided_connection = config.attributes.get("connection") + if provided_connection is not None: + _run_with_connection(provided_connection) + return + + with engine.connect() as connection: + _run_with_connection(connection) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..4901add --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + """升级数据库结构。""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """回退数据库结构。""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0001_legacy_baseline.py b/backend/alembic/versions/0001_legacy_baseline.py new file mode 100644 index 0000000..bec8521 --- /dev/null +++ b/backend/alembic/versions/0001_legacy_baseline.py @@ -0,0 +1,19 @@ +"""登记 TinyPM 既有数据库结构基线。""" + +from collections.abc import Sequence + + +revision: str = "0001_legacy_baseline" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """既有结构由历史版本创建,本迁移只作为版本基线。""" + pass + + +def downgrade() -> None: + """基线迁移不删除既有业务表。""" + pass diff --git a/backend/alembic/versions/0002_weekly_reports.py b/backend/alembic/versions/0002_weekly_reports.py new file mode 100644 index 0000000..8b2c216 --- /dev/null +++ b/backend/alembic/versions/0002_weekly_reports.py @@ -0,0 +1,231 @@ +"""新增证据驱动的项目周报表。""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0002_weekly_reports" +down_revision: str | None = "0001_legacy_baseline" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """创建周报、事实、证据和版本表。""" + op.create_table( + "project_weekly_reports", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="周报ID"), + sa.Column("project_id", sa.Integer(), nullable=False, comment="关联项目ID"), + sa.Column("week_start", sa.Date(), nullable=False, comment="统计开始日期"), + sa.Column("week_end", sa.Date(), nullable=False, comment="统计结束日期"), + sa.Column("timezone", sa.String(length=64), nullable=False, comment="统计时区"), + sa.Column("cutoff_at", sa.DateTime(timezone=True), nullable=False, comment="数据截止时间"), + sa.Column("title", sa.String(length=256), nullable=False, comment="周报标题"), + sa.Column("status", sa.String(length=32), nullable=False, comment="周报状态"), + sa.Column("overall_status", sa.String(length=32), nullable=True, comment="人工确认的项目总体状态"), + sa.Column( + "prompt_template_key", + sa.String(length=64), + nullable=False, + comment="提示词模板标识", + ), + sa.Column("prompt_version", sa.String(length=32), nullable=False, comment="提示词版本"), + sa.Column("model_name", sa.String(length=128), nullable=True, comment="生成草稿使用的模型"), + sa.Column("created_by", sa.Integer(), nullable=False, comment="创建人ID"), + sa.Column("reviewed_by", sa.Integer(), nullable=True, comment="最近审核人ID"), + sa.Column("approved_by", sa.Integer(), nullable=True, comment="批准人ID"), + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True, comment="批准时间"), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True, comment="归档时间"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint(["approved_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "project_id", + "week_start", + "week_end", + name="uq_project_weekly_report_period", + ), + ) + op.create_index( + "ix_project_weekly_reports_project_status", + "project_weekly_reports", + ["project_id", "status"], + unique=False, + ) + + op.create_table( + "project_weekly_report_facts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="事实ID"), + sa.Column("report_id", sa.Integer(), nullable=False, comment="关联周报ID"), + sa.Column("source_key", sa.String(length=255), nullable=True, comment="自动采集事实稳定标识"), + sa.Column("category", sa.String(length=32), nullable=False, comment="事实分类"), + sa.Column("module", sa.String(length=128), nullable=True, comment="所属模块"), + sa.Column("statement", sa.Text(), nullable=False, comment="事实陈述"), + sa.Column("business_status", sa.String(length=32), nullable=True, comment="业务状态"), + sa.Column("review_status", sa.String(length=32), nullable=False, comment="事实审核状态"), + sa.Column("audience_scope", sa.String(length=32), nullable=False, comment="受众范围"), + sa.Column("owner", sa.String(length=64), nullable=True, comment="负责人"), + sa.Column("action_due_at", sa.Date(), nullable=True, comment="下一步动作截止日期"), + sa.Column("next_action", sa.Text(), nullable=True, comment="下一步动作"), + sa.Column("impact_type", sa.String(length=32), nullable=True, comment="影响类型"), + sa.Column("impact_description", sa.Text(), nullable=True, comment="影响说明"), + sa.Column("decision_required", sa.Boolean(), nullable=False, comment="是否需要决策"), + sa.Column("decision_owner", sa.String(length=64), nullable=True, comment="决策人"), + sa.Column("decision_due_at", sa.Date(), nullable=True, comment="决策截止日期"), + sa.Column("date_type", sa.String(length=16), nullable=True, comment="日期语义"), + sa.Column("date_value", sa.Date(), nullable=True, comment="事实日期"), + sa.Column("previous_value_json", sa.Text(), nullable=True, comment="上期值JSON"), + sa.Column("current_value_json", sa.Text(), nullable=True, comment="本期值JSON"), + sa.Column("source_mode", sa.String(length=32), nullable=False, comment="事实来源模式"), + sa.Column("created_by", sa.Integer(), nullable=True, comment="创建人ID"), + sa.Column("verified_by", sa.Integer(), nullable=True, comment="核验人ID"), + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True, comment="核验时间"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["report_id"], ["project_weekly_reports.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["verified_by"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("report_id", "source_key", name="uq_weekly_report_fact_source_key"), + ) + op.create_index( + "ix_weekly_report_facts_report_review", + "project_weekly_report_facts", + ["report_id", "review_status"], + unique=False, + ) + + op.create_table( + "project_weekly_report_evidence", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="证据ID"), + sa.Column("fact_id", sa.Integer(), nullable=False, comment="关联事实ID"), + sa.Column("source_type", sa.String(length=32), nullable=False, comment="来源类型"), + sa.Column("source_id", sa.String(length=128), nullable=True, comment="来源记录ID"), + sa.Column("source_uri", sa.String(length=512), nullable=True, comment="来源链接"), + sa.Column("source_title", sa.String(length=256), nullable=True, comment="来源标题"), + sa.Column("source_excerpt", sa.Text(), nullable=True, comment="来源摘录"), + sa.Column( + "source_updated_at", + sa.DateTime(timezone=True), + nullable=True, + comment="来源更新时间", + ), + sa.Column("source_content_hash", sa.String(length=64), nullable=True, comment="来源内容哈希"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint(["fact_id"], ["project_weekly_report_facts.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_weekly_report_evidence_fact", + "project_weekly_report_evidence", + ["fact_id"], + unique=False, + ) + + op.create_table( + "project_weekly_report_versions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="版本ID"), + sa.Column("report_id", sa.Integer(), nullable=False, comment="关联周报ID"), + sa.Column("audience_type", sa.String(length=32), nullable=False, comment="受众类型"), + sa.Column("version_no", sa.Integer(), nullable=False, comment="版本号"), + sa.Column("version_stage", sa.String(length=32), nullable=False, comment="版本阶段"), + sa.Column("content_markdown", sa.Text(), nullable=False, comment="周报Markdown内容"), + sa.Column("raw_output", sa.Text(), nullable=True, comment="模型原始输出"), + sa.Column("fact_ids_json", sa.Text(), nullable=False, comment="引用事实ID列表JSON"), + sa.Column( + "fact_hashes_json", + sa.Text(), + server_default=sa.text("'{}'"), + nullable=False, + comment="引用事实快照哈希JSON", + ), + sa.Column("prompt_version", sa.String(length=32), nullable=False, comment="提示词版本"), + sa.Column("model_name", sa.String(length=128), nullable=True, comment="生成模型"), + sa.Column("generated_by", sa.Integer(), nullable=True, comment="生成人ID"), + sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False, comment="生成时间"), + sa.Column("approved_by", sa.Integer(), nullable=True, comment="批准人ID"), + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True, comment="批准时间"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint(["approved_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["generated_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["report_id"], ["project_weekly_reports.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "report_id", + "audience_type", + "version_no", + name="uq_weekly_report_version_number", + ), + ) + op.create_index( + "ix_weekly_report_versions_report_audience", + "project_weekly_report_versions", + ["report_id", "audience_type"], + unique=False, + ) + + +def downgrade() -> None: + """删除项目周报相关表。""" + op.drop_index( + "ix_weekly_report_versions_report_audience", + table_name="project_weekly_report_versions", + ) + op.drop_table("project_weekly_report_versions") + op.drop_index( + "ix_weekly_report_evidence_fact", + table_name="project_weekly_report_evidence", + ) + op.drop_table("project_weekly_report_evidence") + op.drop_index( + "ix_weekly_report_facts_report_review", + table_name="project_weekly_report_facts", + ) + op.drop_table("project_weekly_report_facts") + op.drop_index( + "ix_project_weekly_reports_project_status", + table_name="project_weekly_reports", + ) + op.drop_table("project_weekly_reports") diff --git a/backend/alembic/versions/0003_legacy_compatibility.py b/backend/alembic/versions/0003_legacy_compatibility.py new file mode 100644 index 0000000..7d9cb67 --- /dev/null +++ b/backend/alembic/versions/0003_legacy_compatibility.py @@ -0,0 +1,442 @@ +"""补齐历史数据库兼容字段、关键路径结构和周报查询索引。""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import Connection + + +revision: str = "0003_legacy_compatibility" +down_revision: str | None = "0002_weekly_reports" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _connection() -> Connection: + """返回当前迁移连接。""" + return op.get_bind() + + +def _table_exists(table_name: str) -> bool: + """判断数据表是否存在。""" + return table_name in sa.inspect(_connection()).get_table_names() + + +def _column_names(table_name: str) -> set[str]: + """返回指定数据表的字段名集合。""" + if not _table_exists(table_name): + return set() + return { + str(column["name"]) + for column in sa.inspect(_connection()).get_columns(table_name) + } + + +def _index_names(table_name: str) -> set[str]: + """返回指定数据表的索引名集合。""" + if not _table_exists(table_name): + return set() + return { + str(index["name"]) + for index in sa.inspect(_connection()).get_indexes(table_name) + if index.get("name") + } + + +def _add_column_if_missing( + table_name: str, + column: sa.Column, + *, + remove_server_default: bool = False, +) -> None: + """仅在字段缺失时新增字段,并按需移除临时回填默认值。""" + if not _table_exists(table_name) or column.name in _column_names(table_name): + return + op.add_column(table_name, column) + if remove_server_default: + op.alter_column(table_name, str(column.name), server_default=None) + + +def _has_foreign_key( + table_name: str, + constrained_column: str, + referred_table: str, + referred_column: str, +) -> bool: + """判断指定单字段外键是否已经存在。""" + if not _table_exists(table_name): + return False + for foreign_key in sa.inspect(_connection()).get_foreign_keys(table_name): + if ( + foreign_key.get("constrained_columns") == [constrained_column] + and foreign_key.get("referred_table") == referred_table + and foreign_key.get("referred_columns") == [referred_column] + ): + return True + return False + + +def _clear_orphan_agent_project_ids() -> None: + """清空无法引用现有项目的历史会话项目ID,并保留会话记录。""" + if not _table_exists("agent_sessions") or not _table_exists("projects"): + return + if "project_id" not in _column_names("agent_sessions"): + return + op.execute( + sa.text( + """ + UPDATE agent_sessions AS session + SET project_id = NULL + WHERE session.project_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM projects AS project + WHERE project.id = session.project_id + ) + """ + ) + ) + + +def _create_index_if_missing( + index_name: str, + table_name: str, + columns: list[str], +) -> None: + """仅在表和字段完整且同名索引缺失时创建索引。""" + if not _table_exists(table_name): + return + if not set(columns).issubset(_column_names(table_name)): + return + if index_name in _index_names(table_name): + return + op.create_index(index_name, table_name, columns, unique=False) + + +def _ensure_user_columns() -> None: + """补齐历史 users 表的平台用户字段。""" + _add_column_if_missing( + "users", + sa.Column("display_name", sa.String(length=64), nullable=True, comment="展示名称"), + ) + _add_column_if_missing( + "users", + sa.Column( + "role", + sa.String(length=32), + nullable=False, + server_default=sa.text("'member'"), + comment="权限角色", + ), + remove_server_default=True, + ) + _add_column_if_missing( + "users", + sa.Column( + "is_active", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + comment="是否启用", + ), + remove_server_default=True, + ) + _add_column_if_missing( + "users", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + comment="创建时间", + ), + ) + _add_column_if_missing( + "users", + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + comment="更新时间", + ), + ) + + +def _ensure_agent_columns() -> None: + """补齐历史智能助理会话和消息字段。""" + _add_column_if_missing( + "agent_sessions", + sa.Column("project_id", sa.Integer(), nullable=True, comment="当前项目ID"), + ) + _add_column_if_missing( + "agent_sessions", + sa.Column( + "title", + sa.String(length=128), + nullable=False, + server_default=sa.text("'新对话'"), + comment="会话标题", + ), + remove_server_default=True, + ) + _add_column_if_missing( + "agent_sessions", + sa.Column( + "skill_id", + sa.String(length=64), + nullable=False, + server_default=sa.text("'general'"), + comment="技能ID", + ), + remove_server_default=True, + ) + _add_column_if_missing( + "agent_sessions", + sa.Column("system_prompt", sa.Text(), nullable=True, comment="系统角色设定Prompt"), + ) + _add_column_if_missing( + "agent_sessions", + sa.Column( + "last_message_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + comment="最后消息时间", + ), + ) + if ( + _table_exists("projects") + and "project_id" in _column_names("agent_sessions") + and not _has_foreign_key("agent_sessions", "project_id", "projects", "id") + ): + _clear_orphan_agent_project_ids() + op.create_foreign_key( + "fk_agent_sessions_project_id_projects", + "agent_sessions", + "projects", + ["project_id"], + ["id"], + ondelete="SET NULL", + ) + + _add_column_if_missing( + "agent_messages", + sa.Column("model", sa.String(length=128), nullable=True, comment="响应模型"), + ) + _add_column_if_missing( + "agent_messages", + sa.Column("tool_calls_json", sa.Text(), nullable=True, comment="工具调用摘要JSON"), + ) + _add_column_if_missing( + "agent_messages", + sa.Column( + "is_error", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + comment="是否为错误消息", + ), + remove_server_default=True, + ) + + +def _ensure_project_columns() -> None: + """补齐历史项目、文档、计划和成本字段。""" + project_columns: tuple[sa.Column, ...] = ( + sa.Column("announcement_markdown", sa.Text(), nullable=True, comment="项目公告Markdown内容"), + sa.Column("docs_repo_url", sa.String(length=512), nullable=True, comment="文档仓库地址"), + sa.Column("docs_repo_branch", sa.String(length=128), nullable=True, comment="文档仓库分支"), + sa.Column("docs_repo_subpath", sa.String(length=255), nullable=True, comment="文档子目录"), + sa.Column( + "docs_last_synced_at", + sa.DateTime(timezone=True), + nullable=True, + comment="文档最后同步时间", + ), + sa.Column("docs_sync_status", sa.String(length=32), nullable=True, comment="文档同步状态"), + sa.Column("docs_sync_message", sa.Text(), nullable=True, comment="文档同步消息"), + ) + for column in project_columns: + _add_column_if_missing("projects", column) + + _add_column_if_missing( + "project_document_files", + sa.Column("directory", sa.String(length=255), nullable=True, comment="所属目录"), + ) + + nullable_plan_columns: tuple[sa.Column, ...] = ( + sa.Column("task_code", sa.String(length=64), nullable=True, comment="任务编码"), + sa.Column("primary_task", sa.String(length=256), nullable=True, comment="一级任务"), + sa.Column("secondary_task", sa.String(length=256), nullable=True, comment="二级任务"), + sa.Column("dependency", sa.String(length=256), nullable=True, comment="依赖项"), + sa.Column("duration", sa.String(length=32), nullable=True, comment="工期"), + sa.Column("duration_days", sa.Integer(), nullable=True, comment="可计算工期天数"), + ) + for column in nullable_plan_columns: + _add_column_if_missing("project_plans", column) + _add_column_if_missing( + "project_plans", + sa.Column( + "is_summary", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + comment="是否汇总任务", + ), + remove_server_default=True, + ) + _add_column_if_missing( + "project_plans", + sa.Column( + "progress_pct", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + comment="当前进度百分比", + ), + remove_server_default=True, + ) + + _add_column_if_missing( + "project_cost_records", + sa.Column("person", sa.String(length=64), nullable=True, comment="关联人员"), + ) + + +def _ensure_plan_dependencies_table() -> None: + """创建缺失的项目计划依赖表。""" + if _table_exists("project_plan_dependencies"): + return + op.create_table( + "project_plan_dependencies", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="依赖ID"), + sa.Column("project_id", sa.Integer(), nullable=False, comment="关联项目ID"), + sa.Column("predecessor_plan_id", sa.Integer(), nullable=False, comment="前置计划ID"), + sa.Column("successor_plan_id", sa.Integer(), nullable=False, comment="后续计划ID"), + sa.Column("dependency_type", sa.String(length=8), nullable=False, comment="依赖类型"), + sa.Column("lag_days", sa.Integer(), nullable=False, comment="延迟天数"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["predecessor_plan_id"], + ["project_plans.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["successor_plan_id"], + ["project_plans.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "predecessor_plan_id", + "successor_plan_id", + name="uq_project_plan_dependency_edge", + ), + ) + + +def _ensure_knowledge_table() -> None: + """创建缺失的项目知识条目表。""" + if _table_exists("project_knowledge_entries"): + return + op.create_table( + "project_knowledge_entries", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="知识条目ID"), + sa.Column("project_id", sa.Integer(), nullable=False, comment="关联项目ID"), + sa.Column("title", sa.String(length=256), nullable=False, comment="知识标题"), + sa.Column("source_type", sa.String(length=32), nullable=False, comment="来源类型"), + sa.Column("content", sa.Text(), nullable=False, comment="知识正文"), + sa.Column("source_uri", sa.String(length=1024), nullable=True, comment="原始来源地址"), + sa.Column("external_id", sa.String(length=256), nullable=True, comment="外部系统稳定ID"), + sa.Column("tags_json", sa.Text(), nullable=False, comment="标签JSON"), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=True, + comment="资料发生时间", + ), + sa.Column("content_hash", sa.String(length=64), nullable=False, comment="正文SHA256摘要"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "project_id", + "external_id", + name="uq_project_knowledge_external_id", + ), + ) + + +def _ensure_weekly_source_indexes() -> None: + """创建周报采集常用的项目与时间复合索引。""" + index_specs: tuple[tuple[str, str, list[str]], ...] = ( + ( + "ix_project_milestones_project_updated_at", + "project_milestones", + ["project_id", "updated_at"], + ), + ( + "ix_project_plans_project_updated_at", + "project_plans", + ["project_id", "updated_at"], + ), + ( + "ix_project_requirements_project_updated_at", + "project_requirements", + ["project_id", "updated_at"], + ), + ( + "ix_project_issues_project_updated_at", + "project_issues", + ["project_id", "updated_at"], + ), + ( + "ix_project_knowledge_entries_project_updated_at", + "project_knowledge_entries", + ["project_id", "updated_at"], + ), + ( + "ix_project_knowledge_entries_project_occurred_at", + "project_knowledge_entries", + ["project_id", "occurred_at"], + ), + ) + for index_name, table_name, columns in index_specs: + _create_index_if_missing(index_name, table_name, columns) + + +def upgrade() -> None: + """无损补齐历史字段、新增结构以及周报查询索引。""" + _ensure_user_columns() + _ensure_agent_columns() + _ensure_project_columns() + _ensure_plan_dependencies_table() + _ensure_knowledge_table() + _ensure_weekly_source_indexes() + + +def downgrade() -> None: + """为避免删除历史数据,兼容迁移不执行破坏性回退。""" + pass diff --git a/backend/alembic/versions/0004_weekly_report_coverage.py b/backend/alembic/versions/0004_weekly_report_coverage.py new file mode 100644 index 0000000..5596237 --- /dev/null +++ b/backend/alembic/versions/0004_weekly_report_coverage.py @@ -0,0 +1,31 @@ +"""持久化周报最近一次采集覆盖快照。""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0004_weekly_report_coverage" +down_revision: str | None = "0003_legacy_compatibility" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """为周报增加覆盖快照字段。""" + op.add_column( + "project_weekly_reports", + sa.Column( + "coverage_json", + sa.Text(), + server_default=sa.text("'{}'"), + nullable=False, + comment="最近一次采集覆盖快照JSON", + ), + ) + + +def downgrade() -> None: + """移除周报覆盖快照字段。""" + op.drop_column("project_weekly_reports", "coverage_json") diff --git a/backend/alembic/versions/0005_llm_wiki.py b/backend/alembic/versions/0005_llm_wiki.py new file mode 100644 index 0000000..7e6ca91 --- /dev/null +++ b/backend/alembic/versions/0005_llm_wiki.py @@ -0,0 +1,244 @@ +"""新增项目 LLM Wiki 来源、页面、追溯关系和编译批次。""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import Connection + + +revision: str = "0005_llm_wiki" +down_revision: str | None = "0004_weekly_report_coverage" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _connection() -> Connection: + """返回当前迁移连接。""" + return op.get_bind() + + +def _table_exists(table_name: str) -> bool: + """判断数据表是否存在。""" + return table_name in sa.inspect(_connection()).get_table_names() + + +def _index_names(table_name: str) -> set[str]: + """返回指定表已有索引名。""" + if not _table_exists(table_name): + return set() + return { + str(index["name"]) + for index in sa.inspect(_connection()).get_indexes(table_name) + if index.get("name") + } + + +def _create_index_if_missing( + index_name: str, + table_name: str, + columns: list[str], +) -> None: + """仅在表存在且同名索引缺失时创建索引。""" + if not _table_exists(table_name) or index_name in _index_names(table_name): + return + op.create_index(index_name, table_name, columns, unique=False) + + +def _create_sources_table() -> None: + """创建项目 Wiki 不可变来源表。""" + if _table_exists("project_wiki_sources"): + return + op.create_table( + "project_wiki_sources", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="Wiki来源数据库ID"), + sa.Column("project_id", sa.Integer(), nullable=False, comment="关联项目ID"), + sa.Column("author_user_id", sa.Integer(), nullable=True, comment="来源提交用户ID"), + sa.Column("source_id", sa.String(length=64), nullable=False, comment="内容哈希生成的稳定来源ID"), + sa.Column("origin_kind", sa.String(length=32), nullable=False, comment="来源业务类型"), + sa.Column("origin_ref", sa.String(length=256), nullable=True, comment="来源业务记录引用"), + sa.Column("title", sa.String(length=256), nullable=False, comment="来源标题"), + sa.Column("source_type", sa.String(length=32), nullable=False, comment="知识来源分类"), + sa.Column("source_uri", sa.String(length=1024), nullable=True, comment="原始来源地址"), + sa.Column("raw_content", sa.Text(), nullable=False, comment="不可变原文或提取文本快照"), + sa.Column("content_hash", sa.String(length=64), nullable=False, comment="原文SHA256摘要"), + sa.Column("snapshot_hash", sa.String(length=64), nullable=False, comment="来源正文与语义元数据SHA256摘要"), + sa.Column("trust_level", sa.String(length=32), nullable=False, comment="来源可信等级"), + sa.Column("status", sa.String(length=32), nullable=False, comment="来源编译状态"), + sa.Column("compiled_markdown", sa.Text(), nullable=True, comment="来源编译说明Markdown"), + sa.Column("error_message", sa.Text(), nullable=True, comment="提取或编译失败原因"), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=True, comment="资料实际发生时间"), + sa.Column( + "imported_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="来源入箱时间", + ), + sa.Column("compiled_at", sa.DateTime(timezone=True), nullable=True, comment="来源编译完成时间"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint(["author_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "project_id", + "source_id", + name="uq_project_wiki_sources_project_source_id", + ), + ) + + +def _create_pages_table() -> None: + """创建项目 Wiki 长期页面表。""" + if _table_exists("project_wiki_pages"): + return + op.create_table( + "project_wiki_pages", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="Wiki页面ID"), + sa.Column("project_id", sa.Integer(), nullable=False, comment="关联项目ID"), + sa.Column("slug", sa.String(length=160), nullable=False, comment="Obsidian页面稳定标识"), + sa.Column("page_type", sa.String(length=32), nullable=False, comment="Wiki页面类型"), + sa.Column("title", sa.String(length=256), nullable=False, comment="Wiki页面标题"), + sa.Column("summary", sa.Text(), nullable=False, comment="页面检索摘要"), + sa.Column("content_markdown", sa.Text(), nullable=False, comment="页面Markdown正文"), + sa.Column("status", sa.String(length=32), nullable=False, comment="页面状态"), + sa.Column("revision", sa.Integer(), nullable=False, comment="页面修订号"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "project_id", + "slug", + name="uq_project_wiki_pages_project_slug", + ), + ) + + +def _create_page_sources_table() -> None: + """创建 Wiki 页面与来源追溯关系表。""" + if _table_exists("project_wiki_page_sources"): + return + op.create_table( + "project_wiki_page_sources", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="页面来源关系ID"), + sa.Column("page_id", sa.Integer(), nullable=False, comment="Wiki页面ID"), + sa.Column("source_id", sa.Integer(), nullable=False, comment="Wiki来源数据库ID"), + sa.Column("relation_type", sa.String(length=32), nullable=False, comment="页面与来源关系类型"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint(["page_id"], ["project_wiki_pages.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["source_id"], ["project_wiki_sources.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "page_id", + "source_id", + "relation_type", + name="uq_project_wiki_page_sources_relation", + ), + ) + + +def _create_runs_table() -> None: + """创建 Wiki 持久编译批次表。""" + if _table_exists("project_wiki_compilation_runs"): + return + op.create_table( + "project_wiki_compilation_runs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="Wiki编译批次ID"), + sa.Column("project_id", sa.Integer(), nullable=False, comment="关联项目ID"), + sa.Column("requested_by_user_id", sa.Integer(), nullable=True, comment="发起编译用户ID"), + sa.Column("trigger", sa.String(length=32), nullable=False, comment="编译触发方式"), + sa.Column("status", sa.String(length=32), nullable=False, comment="编译批次状态"), + sa.Column("model", sa.String(length=128), nullable=True, comment="实际调用模型"), + sa.Column("source_count", sa.Integer(), nullable=False, comment="领取来源数量"), + sa.Column("page_count", sa.Integer(), nullable=False, comment="新增或更新页面次数"), + sa.Column("lint_errors_json", sa.Text(), nullable=False, comment="Wiki检查错误JSON"), + sa.Column("error_message", sa.Text(), nullable=True, comment="批次失败原因"), + sa.Column( + "started_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="开始时间", + ), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True, comment="结束时间"), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + + +def upgrade() -> None: + """创建 LLM Wiki 结构,并兼容空库 create_all 已提前建表的情况。""" + _create_sources_table() + _create_pages_table() + _create_page_sources_table() + _create_runs_table() + _create_index_if_missing( + "ix_project_wiki_sources_project_status_imported", + "project_wiki_sources", + ["project_id", "status", "imported_at"], + ) + _create_index_if_missing( + "ix_project_wiki_sources_project_origin", + "project_wiki_sources", + ["project_id", "origin_kind", "origin_ref"], + ) + _create_index_if_missing( + "ix_project_wiki_pages_project_type_updated", + "project_wiki_pages", + ["project_id", "page_type", "updated_at"], + ) + _create_index_if_missing( + "ix_project_wiki_page_sources_source_page", + "project_wiki_page_sources", + ["source_id", "page_id"], + ) + _create_index_if_missing( + "ix_project_wiki_compilation_runs_project_started", + "project_wiki_compilation_runs", + ["project_id", "started_at"], + ) + + +def downgrade() -> None: + """按依赖逆序移除 LLM Wiki 结构。""" + for table_name in ( + "project_wiki_page_sources", + "project_wiki_compilation_runs", + "project_wiki_pages", + "project_wiki_sources", + ): + if _table_exists(table_name): + op.drop_table(table_name) diff --git a/backend/app/config.py b/backend/app/config.py index 919140a..684feee 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,12 +19,22 @@ class Settings(BaseSettings): INITIAL_ADMIN_USERNAME: str = "admin" INITIAL_ADMIN_PASSWORD: str = "admin" INITIAL_ADMIN_DISPLAY_NAME: str = "平台管理员" + INTEGRATION_API_KEY: str | None = None UPLOAD_DIR: str = "uploads" DOCUMENTS_DIR: str = "documents" AGENT_DEFAULT_MODEL: str = "deepseek-v4-flash" AGENT_DEFAULT_API_BASE_URL: str = "https://api.deepseek.com/v1" + WIKI_AUTO_COMPILE_ENABLED: bool = True + WIKI_WORKER_POLL_SECONDS: int = 10 + WIKI_COMPILATION_STALE_MINUTES: int = 60 + WIKI_MAX_DOCUMENT_BYTES: int = 52_428_800 + WIKI_MAX_EXTRACTED_CHARS: int = 500_000 + WIKI_SOURCE_CHUNK_CHARS: int = 12_000 + WIKI_MAX_CHUNKS_PER_SOURCE: int = 8 + WIKI_MAX_SOURCES_PER_RUN: int = 20 + class Config: """环境变量读取配置。""" diff --git a/backend/app/deps.py b/backend/app/deps.py index 6ef7bf6..ec10a7e 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -1,16 +1,20 @@ """认证依赖模块。""" from collections.abc import Iterable +import secrets from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +from app.config import settings from app.database import get_db from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MANAGER, User from app.services.auth import decode_access_token security = HTTPBearer(auto_error=False) +integration_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) +integration_bearer = HTTPBearer(auto_error=False) def get_current_user( @@ -79,3 +83,27 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User: def require_manager_or_admin(current_user: User = Depends(get_current_user)) -> User: """要求当前用户具备管理员或经理权限。""" return _ensure_role(current_user, [USER_ROLE_ADMIN, USER_ROLE_MANAGER]) + + +def require_integration_api_key( + header_key: str | None = Depends(integration_key_header), + bearer_credentials: HTTPAuthorizationCredentials | None = Depends(integration_bearer), +) -> None: + """校验 Codex 等外部客户端使用的独立集成密钥。""" + configured_key = (settings.INTEGRATION_API_KEY or "").strip() + if not configured_key: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="集成 API 未启用,请先配置 INTEGRATION_API_KEY", + ) + + provided_key = (header_key or "").strip() + if not provided_key and bearer_credentials is not None: + provided_key = bearer_credentials.credentials.strip() + + if not provided_key or not secrets.compare_digest(provided_key, configured_key): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的集成 API Key", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/backend/app/main.py b/backend/app/main.py index c0dfd38..41bd524 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,11 +6,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from sqlalchemy import inspect, text from app.config import settings -from app.database import Base, SessionLocal, engine -from app.routers import agent, auth, members, project_mcp, projects, users +from app.database import SessionLocal +from app.migrations import run_migrations +from app.routers import agent, auth, integration, members, project_mcp, projects, users, weekly_reports, wiki logging.basicConfig( level=logging.INFO, @@ -21,7 +21,7 @@ app = FastAPI( title="TinyPM API", description="独立项目管理平台后端 API", - version="1.1.0", + version="1.2.0", ) app.add_middleware( @@ -35,8 +35,11 @@ app.include_router(auth.router) app.include_router(agent.router) app.include_router(project_mcp.router) +app.include_router(integration.router) app.include_router(users.router) app.include_router(members.router) +app.include_router(weekly_reports.router) +app.include_router(wiki.router) app.include_router(projects.router) Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True) Path(settings.DOCUMENTS_DIR).mkdir(parents=True, exist_ok=True) @@ -47,12 +50,7 @@ @app.on_event("startup") def on_startup() -> None: """应用启动时初始化数据库。""" - logger.info("正在初始化 TinyPM 数据库表") - Base.metadata.create_all(bind=engine) - _ensure_user_schema() - _ensure_agent_schema() - _ensure_project_schema() - logger.info("数据库表初始化完成") + run_migrations() from app.models import seed_all @@ -69,184 +67,6 @@ def on_startup() -> None: logger.info("TinyPM 后端启动完成") -def _ensure_user_schema() -> None: - """为旧版本 users 表补齐平台字段。""" - inspector = inspect(engine) - if "users" not in inspector.get_table_names(): - return - - column_names = {column["name"] for column in inspector.get_columns("users")} - statements: list[str] = [] - - if "display_name" not in column_names: - statements.append("ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name VARCHAR(64)") - if "role" not in column_names: - statements.append( - "ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(32) NOT NULL DEFAULT 'member'" - ) - if "is_active" not in column_names: - statements.append( - "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE" - ) - if "created_at" not in column_names: - statements.append( - "ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()" - ) - if "updated_at" not in column_names: - statements.append( - "ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()" - ) - - if not statements: - return - - with engine.begin() as connection: - for statement in statements: - connection.execute(text(statement)) - - logger.info("users 表结构补齐完成") - - -def _ensure_agent_schema() -> None: - """为旧版本 agent 表补齐会话字段。""" - inspector = inspect(engine) - table_names = set(inspector.get_table_names()) - statements: list[str] = [] - - if "agent_sessions" in table_names: - session_column_names = { - column["name"] for column in inspector.get_columns("agent_sessions") - } - if "project_id" not in session_column_names: - statements.append("ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS project_id INTEGER") - if "title" not in session_column_names: - statements.append( - "ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS title VARCHAR(128) NOT NULL DEFAULT '新对话'" - ) - if "skill_id" not in session_column_names: - statements.append( - "ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS skill_id VARCHAR(64) NOT NULL DEFAULT 'general'" - ) - if "system_prompt" not in session_column_names: - statements.append("ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS system_prompt TEXT") - if "last_message_at" not in session_column_names: - statements.append( - "ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ NOT NULL DEFAULT NOW()" - ) - - if "agent_messages" in table_names: - message_column_names = { - column["name"] for column in inspector.get_columns("agent_messages") - } - if "model" not in message_column_names: - statements.append("ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS model VARCHAR(128)") - if "tool_calls_json" not in message_column_names: - statements.append("ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS tool_calls_json TEXT") - if "is_error" not in message_column_names: - statements.append( - "ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS is_error BOOLEAN NOT NULL DEFAULT FALSE" - ) - - if not statements: - return - - with engine.begin() as connection: - for statement in statements: - connection.execute(text(statement)) - - logger.info("agent 表结构补齐完成") - - -def _ensure_project_schema() -> None: - """为旧版本 projects 表补齐扩展字段。""" - inspector = inspect(engine) - if "projects" not in inspector.get_table_names(): - return - - column_names = {column["name"] for column in inspector.get_columns("projects")} - statements: list[str] = [] - - if "announcement_markdown" not in column_names: - statements.append( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS announcement_markdown TEXT" - ) - if "docs_repo_url" not in column_names: - statements.append( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS docs_repo_url VARCHAR(512)" - ) - if "docs_repo_branch" not in column_names: - statements.append( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS docs_repo_branch VARCHAR(128)" - ) - if "docs_repo_subpath" not in column_names: - statements.append( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS docs_repo_subpath VARCHAR(255)" - ) - if "docs_last_synced_at" not in column_names: - statements.append( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS docs_last_synced_at TIMESTAMPTZ" - ) - if "docs_sync_status" not in column_names: - statements.append( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS docs_sync_status VARCHAR(32)" - ) - if "docs_sync_message" not in column_names: - statements.append( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS docs_sync_message TEXT" - ) - if "project_document_files" in inspector.get_table_names(): - document_column_names = { - column["name"] for column in inspector.get_columns("project_document_files") - } - if "directory" not in document_column_names: - statements.append( - "ALTER TABLE project_document_files ADD COLUMN IF NOT EXISTS directory VARCHAR(255)" - ) - - if "project_plans" in inspector.get_table_names(): - plan_column_names = { - column["name"] for column in inspector.get_columns("project_plans") - } - if "primary_task" not in plan_column_names: - statements.append( - "ALTER TABLE project_plans ADD COLUMN IF NOT EXISTS primary_task VARCHAR(256)" - ) - if "secondary_task" not in plan_column_names: - statements.append( - "ALTER TABLE project_plans ADD COLUMN IF NOT EXISTS secondary_task VARCHAR(256)" - ) - if "dependency" not in plan_column_names: - statements.append( - "ALTER TABLE project_plans ADD COLUMN IF NOT EXISTS dependency VARCHAR(256)" - ) - if "duration" not in plan_column_names: - statements.append( - "ALTER TABLE project_plans ADD COLUMN IF NOT EXISTS duration VARCHAR(32)" - ) - if "progress_pct" not in plan_column_names: - statements.append( - "ALTER TABLE project_plans ADD COLUMN IF NOT EXISTS progress_pct INTEGER NOT NULL DEFAULT 0" - ) - - if "project_cost_records" in inspector.get_table_names(): - cost_column_names = { - column["name"] for column in inspector.get_columns("project_cost_records") - } - if "person" not in cost_column_names: - statements.append( - "ALTER TABLE project_cost_records ADD COLUMN IF NOT EXISTS person VARCHAR(64)" - ) - - if not statements: - return - - with engine.begin() as connection: - for statement in statements: - connection.execute(text(statement)) - - logger.info("projects / project_cost_records 表结构补齐完成") - - @app.get("/api/health", tags=["系统"]) def health_check() -> dict[str, str]: """健康检查接口。""" diff --git a/backend/app/migrations.py b/backend/app/migrations.py new file mode 100644 index 0000000..3f8cab6 --- /dev/null +++ b/backend/app/migrations.py @@ -0,0 +1,93 @@ +"""数据库迁移启动器。""" + +import logging +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import inspect, text +from sqlalchemy.engine import Connection + +from app.database import Base, engine + + +logger = logging.getLogger(__name__) + +LEGACY_BASELINE_REVISION = "0001_legacy_baseline" +WEEKLY_TABLE_PREFIX = "project_weekly_" +CORE_LEGACY_TABLES = {"users", "projects"} +POSTGRES_MIGRATION_LOCK_ID = 862_014_937 + + +def _build_alembic_config(connection: Connection) -> Config: + """创建绑定当前数据库连接的 Alembic 配置。""" + backend_dir = Path(__file__).resolve().parents[1] + config = Config(str(backend_dir / "alembic.ini")) + config.attributes["connection"] = connection + return config + + +def _load_all_models() -> None: + """导入模型包,确保声明式元数据完整。""" + import app.models + + +def _create_missing_legacy_schema(connection: Connection) -> None: + """补建当前模型中缺失的非周报表,不改动已有表和数据。""" + _load_all_models() + legacy_tables = [ + table + for table in Base.metadata.sorted_tables + if not table.name.startswith(WEEKLY_TABLE_PREFIX) + ] + Base.metadata.create_all(bind=connection, tables=legacy_tables) + + +def _prepare_unversioned_database(connection: Connection, table_names: set[str]) -> None: + """将空库或历史数据库登记到无操作基线。""" + config = _build_alembic_config(connection) + business_tables = table_names - {"alembic_version"} + if not business_tables: + logger.info("检测到空数据库,正在创建 TinyPM 既有结构") + _create_missing_legacy_schema(connection) + else: + weekly_tables = { + table_name + for table_name in business_tables + if table_name.startswith(WEEKLY_TABLE_PREFIX) + } + if weekly_tables: + names = "、".join(sorted(weekly_tables)) + raise RuntimeError( + f"未登记版本的数据库已存在周报表:{names},请人工核对后再执行迁移" + ) + + missing_core_tables = CORE_LEGACY_TABLES - business_tables + if missing_core_tables: + names = "、".join(sorted(missing_core_tables)) + raise RuntimeError( + f"检测到不完整的历史数据库,缺少核心表:{names},拒绝自动登记迁移基线" + ) + logger.info("检测到未登记版本的历史数据库,正在补建缺失的既有业务表") + _create_missing_legacy_schema(connection) + logger.info("缺失业务表补建完成,正在登记既有结构基线") + + command.stamp(config, LEGACY_BASELINE_REVISION) + + +def run_migrations() -> None: + """登记历史结构并将数据库升级到最新版本。""" + with engine.begin() as connection: + if connection.dialect.name == "postgresql": + connection.execute( + text("SELECT pg_advisory_xact_lock(:lock_id)"), + {"lock_id": POSTGRES_MIGRATION_LOCK_ID}, + ) + + table_names = set(inspect(connection).get_table_names()) + if "alembic_version" not in table_names: + _prepare_unversioned_database(connection, table_names) + + logger.info("正在执行数据库迁移") + command.upgrade(_build_alembic_config(connection), "head") + logger.info("数据库迁移完成") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fd0d042..885b5dc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,11 +11,25 @@ ProjectDocumentFile, ProjectIssue, ProjectIssueImage, + ProjectKnowledgeEntry, ProjectMilestone, ProjectPlan, + ProjectPlanDependency, ProjectRequirement, ) from app.models.user import USER_ROLE_ADMIN, User +from app.models.weekly_report import ( + ProjectWeeklyReport, + ProjectWeeklyReportEvidence, + ProjectWeeklyReportFact, + ProjectWeeklyReportVersion, +) +from app.models.wiki import ( + ProjectWikiCompilationRun, + ProjectWikiPage, + ProjectWikiPageSource, + ProjectWikiSource, +) __all__ = [ "User", @@ -26,11 +40,21 @@ "Project", "ProjectMilestone", "ProjectPlan", + "ProjectPlanDependency", "ProjectRequirement", "ProjectIssue", "ProjectIssueImage", + "ProjectKnowledgeEntry", "ProjectCostRecord", "ProjectDocumentFile", + "ProjectWeeklyReport", + "ProjectWeeklyReportFact", + "ProjectWeeklyReportEvidence", + "ProjectWeeklyReportVersion", + "ProjectWikiSource", + "ProjectWikiPage", + "ProjectWikiPageSource", + "ProjectWikiCompilationRun", ] diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 6ca803c..81a66d5 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,6 +1,19 @@ """项目管理模型。""" -from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func +from sqlalchemy import ( + Boolean, + Column, + Date, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, + func, +) from sqlalchemy.orm import relationship from app.database import Base @@ -55,6 +68,12 @@ class Project(Base): cascade="all, delete-orphan", lazy="selectin", ) + plan_dependencies = relationship( + "ProjectPlanDependency", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) requirements = relationship( "ProjectRequirement", back_populates="project", @@ -79,6 +98,12 @@ class Project(Base): cascade="all, delete-orphan", lazy="selectin", ) + knowledge_entries = relationship( + "ProjectKnowledgeEntry", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) def __repr__(self) -> str: """返回调试信息。""" @@ -89,6 +114,13 @@ class ProjectMilestone(Base): """项目里程碑。""" __tablename__ = "project_milestones" + __table_args__ = ( + Index( + "ix_project_milestones_project_updated_at", + "project_id", + "updated_at", + ), + ) id = Column(Integer, primary_key=True, autoincrement=True, comment="里程碑ID") project_id = Column( @@ -128,6 +160,9 @@ class ProjectPlan(Base): """项目计划。""" __tablename__ = "project_plans" + __table_args__ = ( + Index("ix_project_plans_project_updated_at", "project_id", "updated_at"), + ) id = Column(Integer, primary_key=True, autoincrement=True, comment="计划ID") project_id = Column( @@ -136,11 +171,14 @@ class ProjectPlan(Base): nullable=False, comment="关联项目ID", ) + task_code = Column(String(64), nullable=True, comment="任务编码") phase_name = Column(String(256), nullable=False, comment="阶段名称") primary_task = Column(String(256), nullable=True, comment="一级任务") secondary_task = Column(String(256), nullable=True, comment="二级任务") dependency = Column(String(256), nullable=True, comment="依赖项") duration = Column(String(32), nullable=True, comment="工期") + duration_days = Column(Integer, nullable=True, comment="可计算工期天数") + is_summary = Column(Boolean, nullable=False, default=False, comment="是否汇总任务") progress_pct = Column(Integer, nullable=False, default=0, comment="当前进度百分比") description = Column(Text, nullable=True, comment="描述") planned_start = Column(Date, nullable=True, comment="计划开始日期") @@ -164,16 +202,97 @@ class ProjectPlan(Base): ) project = relationship("Project", back_populates="plans") + predecessor_links = relationship( + "ProjectPlanDependency", + foreign_keys="ProjectPlanDependency.successor_plan_id", + back_populates="successor", + cascade="all, delete-orphan", + lazy="selectin", + ) + successor_links = relationship( + "ProjectPlanDependency", + foreign_keys="ProjectPlanDependency.predecessor_plan_id", + back_populates="predecessor", + cascade="all, delete-orphan", + lazy="selectin", + ) def __repr__(self) -> str: """返回调试信息。""" return f"" +class ProjectPlanDependency(Base): + """项目计划依赖关系。""" + + __tablename__ = "project_plan_dependencies" + __table_args__ = ( + UniqueConstraint( + "predecessor_plan_id", + "successor_plan_id", + name="uq_project_plan_dependency_edge", + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="依赖ID") + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联项目ID", + ) + predecessor_plan_id = Column( + Integer, + ForeignKey("project_plans.id", ondelete="CASCADE"), + nullable=False, + comment="前置计划ID", + ) + successor_plan_id = Column( + Integer, + ForeignKey("project_plans.id", ondelete="CASCADE"), + nullable=False, + comment="后续计划ID", + ) + dependency_type = Column(String(8), nullable=False, default="FS", comment="依赖类型") + lag_days = Column(Integer, nullable=False, default=0, comment="延迟天数") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + + project = relationship("Project", back_populates="plan_dependencies") + predecessor = relationship( + "ProjectPlan", + foreign_keys=[predecessor_plan_id], + back_populates="successor_links", + ) + successor = relationship( + "ProjectPlan", + foreign_keys=[successor_plan_id], + back_populates="predecessor_links", + ) + + def __repr__(self) -> str: + """返回调试信息。""" + return ( + f"" + ) + + class ProjectRequirement(Base): """项目需求。""" __tablename__ = "project_requirements" + __table_args__ = ( + Index( + "ix_project_requirements_project_updated_at", + "project_id", + "updated_at", + ), + ) id = Column(Integer, primary_key=True, autoincrement=True, comment="需求ID") project_id = Column( @@ -213,6 +332,9 @@ class ProjectIssue(Base): """项目问题。""" __tablename__ = "project_issues" + __table_args__ = ( + Index("ix_project_issues_project_updated_at", "project_id", "updated_at"), + ) id = Column(Integer, primary_key=True, autoincrement=True, comment="问题ID") project_id = Column( @@ -327,6 +449,67 @@ def __repr__(self) -> str: ) +class ProjectKnowledgeEntry(Base): + """项目知识条目。""" + + __tablename__ = "project_knowledge_entries" + __table_args__ = ( + UniqueConstraint( + "project_id", + "external_id", + name="uq_project_knowledge_external_id", + ), + Index( + "ix_project_knowledge_entries_project_updated_at", + "project_id", + "updated_at", + ), + Index( + "ix_project_knowledge_entries_project_occurred_at", + "project_id", + "occurred_at", + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="知识条目ID") + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联项目ID", + ) + title = Column(String(256), nullable=False, comment="知识标题") + source_type = Column(String(32), nullable=False, default="other", comment="来源类型") + content = Column(Text, nullable=False, comment="知识正文") + source_uri = Column(String(1024), nullable=True, comment="原始来源地址") + external_id = Column(String(256), nullable=True, comment="外部系统稳定ID") + tags_json = Column(Text, nullable=False, default="[]", comment="标签JSON") + occurred_at = Column(DateTime(timezone=True), nullable=True, comment="资料发生时间") + content_hash = Column(String(64), nullable=False, comment="正文SHA256摘要") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="更新时间", + ) + + project = relationship("Project", back_populates="knowledge_entries") + + def __repr__(self) -> str: + """返回调试信息。""" + return ( + f"" + ) + + class ProjectDocumentFile(Base): """项目文档文件。""" diff --git a/backend/app/models/weekly_report.py b/backend/app/models/weekly_report.py new file mode 100644 index 0000000..566be67 --- /dev/null +++ b/backend/app/models/weekly_report.py @@ -0,0 +1,298 @@ +"""项目周报模型。""" + +from sqlalchemy import ( + Boolean, + Column, + Date, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import relationship + +from app.database import Base + + +class ProjectWeeklyReport(Base): + """项目周报主表。""" + + __tablename__ = "project_weekly_reports" + __table_args__ = ( + UniqueConstraint( + "project_id", + "week_start", + "week_end", + name="uq_project_weekly_report_period", + ), + Index("ix_project_weekly_reports_project_status", "project_id", "status"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="周报ID") + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联项目ID", + ) + week_start = Column(Date, nullable=False, comment="统计开始日期") + week_end = Column(Date, nullable=False, comment="统计结束日期") + timezone = Column(String(64), nullable=False, default="Asia/Shanghai", comment="统计时区") + cutoff_at = Column(DateTime(timezone=True), nullable=False, comment="数据截止时间") + title = Column(String(256), nullable=False, comment="周报标题") + status = Column(String(32), nullable=False, default="collecting", comment="周报状态") + overall_status = Column(String(32), nullable=True, comment="人工确认的项目总体状态") + prompt_template_key = Column( + String(64), + nullable=False, + default="weekly_report_canonical", + comment="提示词模板标识", + ) + prompt_version = Column(String(32), nullable=False, default="1.0", comment="提示词版本") + model_name = Column(String(128), nullable=True, comment="生成草稿使用的模型") + coverage_json = Column( + Text, + nullable=False, + default="{}", + server_default="{}", + comment="最近一次采集覆盖快照JSON", + ) + created_by = Column( + Integer, + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + comment="创建人ID", + ) + reviewed_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="最近审核人ID", + ) + approved_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="批准人ID", + ) + approved_at = Column(DateTime(timezone=True), nullable=True, comment="批准时间") + archived_at = Column(DateTime(timezone=True), nullable=True, comment="归档时间") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="更新时间", + ) + + facts = relationship( + "ProjectWeeklyReportFact", + back_populates="report", + cascade="all, delete-orphan", + order_by="ProjectWeeklyReportFact.id", + lazy="selectin", + ) + versions = relationship( + "ProjectWeeklyReportVersion", + back_populates="report", + cascade="all, delete-orphan", + order_by="ProjectWeeklyReportVersion.id", + lazy="selectin", + ) + + def __repr__(self) -> str: + """返回调试信息。""" + return f"" + + +class ProjectWeeklyReportFact(Base): + """周报事实底稿。""" + + __tablename__ = "project_weekly_report_facts" + __table_args__ = ( + UniqueConstraint("report_id", "source_key", name="uq_weekly_report_fact_source_key"), + Index("ix_weekly_report_facts_report_review", "report_id", "review_status"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="事实ID") + report_id = Column( + Integer, + ForeignKey("project_weekly_reports.id", ondelete="CASCADE"), + nullable=False, + comment="关联周报ID", + ) + source_key = Column(String(255), nullable=True, comment="自动采集事实稳定标识") + category = Column(String(32), nullable=False, comment="事实分类") + module = Column(String(128), nullable=True, comment="所属模块") + statement = Column(Text, nullable=False, comment="事实陈述") + business_status = Column(String(32), nullable=True, comment="业务状态") + review_status = Column( + String(32), + nullable=False, + default="pending_confirmation", + comment="事实审核状态", + ) + audience_scope = Column(String(32), nullable=False, default="internal", comment="受众范围") + owner = Column(String(64), nullable=True, comment="负责人") + action_due_at = Column(Date, nullable=True, comment="下一步动作截止日期") + next_action = Column(Text, nullable=True, comment="下一步动作") + impact_type = Column(String(32), nullable=True, comment="影响类型") + impact_description = Column(Text, nullable=True, comment="影响说明") + decision_required = Column(Boolean, nullable=False, default=False, comment="是否需要决策") + decision_owner = Column(String(64), nullable=True, comment="决策人") + decision_due_at = Column(Date, nullable=True, comment="决策截止日期") + date_type = Column(String(16), nullable=True, comment="日期语义") + date_value = Column(Date, nullable=True, comment="事实日期") + previous_value_json = Column(Text, nullable=True, comment="上期值JSON") + current_value_json = Column(Text, nullable=True, comment="本期值JSON") + source_mode = Column(String(32), nullable=False, default="structured", comment="事实来源模式") + created_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="创建人ID", + ) + verified_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="核验人ID", + ) + verified_at = Column(DateTime(timezone=True), nullable=True, comment="核验时间") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="更新时间", + ) + + report = relationship("ProjectWeeklyReport", back_populates="facts") + evidence = relationship( + "ProjectWeeklyReportEvidence", + back_populates="fact", + cascade="all, delete-orphan", + order_by="ProjectWeeklyReportEvidence.id", + lazy="selectin", + ) + + def __repr__(self) -> str: + """返回调试信息。""" + return f"" + + +class ProjectWeeklyReportEvidence(Base): + """周报事实证据。""" + + __tablename__ = "project_weekly_report_evidence" + __table_args__ = (Index("ix_weekly_report_evidence_fact", "fact_id"),) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="证据ID") + fact_id = Column( + Integer, + ForeignKey("project_weekly_report_facts.id", ondelete="CASCADE"), + nullable=False, + comment="关联事实ID", + ) + source_type = Column(String(32), nullable=False, comment="来源类型") + source_id = Column(String(128), nullable=True, comment="来源记录ID") + source_uri = Column(String(512), nullable=True, comment="来源链接") + source_title = Column(String(256), nullable=True, comment="来源标题") + source_excerpt = Column(Text, nullable=True, comment="来源摘录") + source_updated_at = Column(DateTime(timezone=True), nullable=True, comment="来源更新时间") + source_content_hash = Column(String(64), nullable=True, comment="来源内容哈希") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + + fact = relationship("ProjectWeeklyReportFact", back_populates="evidence") + + def __repr__(self) -> str: + """返回调试信息。""" + return f"" + + +class ProjectWeeklyReportVersion(Base): + """周报内容版本。""" + + __tablename__ = "project_weekly_report_versions" + __table_args__ = ( + UniqueConstraint( + "report_id", + "audience_type", + "version_no", + name="uq_weekly_report_version_number", + ), + Index("ix_weekly_report_versions_report_audience", "report_id", "audience_type"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="版本ID") + report_id = Column( + Integer, + ForeignKey("project_weekly_reports.id", ondelete="CASCADE"), + nullable=False, + comment="关联周报ID", + ) + audience_type = Column(String(32), nullable=False, default="canonical", comment="受众类型") + version_no = Column(Integer, nullable=False, comment="版本号") + version_stage = Column(String(32), nullable=False, comment="版本阶段") + content_markdown = Column(Text, nullable=False, comment="周报Markdown内容") + raw_output = Column(Text, nullable=True, comment="模型原始输出") + fact_ids_json = Column(Text, nullable=False, comment="引用事实ID列表JSON") + fact_hashes_json = Column( + Text, + nullable=False, + default="{}", + server_default="{}", + comment="引用事实快照哈希JSON", + ) + prompt_version = Column(String(32), nullable=False, comment="提示词版本") + model_name = Column(String(128), nullable=True, comment="生成模型") + generated_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="生成人ID", + ) + generated_at = Column(DateTime(timezone=True), nullable=False, comment="生成时间") + approved_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="批准人ID", + ) + approved_at = Column(DateTime(timezone=True), nullable=True, comment="批准时间") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + + report = relationship("ProjectWeeklyReport", back_populates="versions") + + def __repr__(self) -> str: + """返回调试信息。""" + return ( + f"" + ) diff --git a/backend/app/models/wiki.py b/backend/app/models/wiki.py new file mode 100644 index 0000000..fa83e79 --- /dev/null +++ b/backend/app/models/wiki.py @@ -0,0 +1,283 @@ +"""项目 LLM Wiki 模型。""" + +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import relationship + +from app.database import Base + + +class ProjectWikiSource(Base): + """项目 Wiki 的不可变来源快照。""" + + __tablename__ = "project_wiki_sources" + __table_args__ = ( + UniqueConstraint( + "project_id", + "source_id", + name="uq_project_wiki_sources_project_source_id", + ), + Index( + "ix_project_wiki_sources_project_status_imported", + "project_id", + "status", + "imported_at", + ), + Index( + "ix_project_wiki_sources_project_origin", + "project_id", + "origin_kind", + "origin_ref", + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="Wiki来源数据库ID") + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联项目ID", + ) + author_user_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="来源提交用户ID", + ) + source_id = Column(String(64), nullable=False, comment="内容哈希生成的稳定来源ID") + origin_kind = Column(String(32), nullable=False, comment="来源业务类型") + origin_ref = Column(String(256), nullable=True, comment="来源业务记录引用") + title = Column(String(256), nullable=False, comment="来源标题") + source_type = Column(String(32), nullable=False, comment="知识来源分类") + source_uri = Column(String(1024), nullable=True, comment="原始来源地址") + raw_content = Column(Text, nullable=False, comment="不可变原文或提取文本快照") + content_hash = Column(String(64), nullable=False, comment="原文SHA256摘要") + snapshot_hash = Column(String(64), nullable=False, comment="来源正文与语义元数据SHA256摘要") + trust_level = Column( + String(32), + nullable=False, + default="unverified", + comment="来源可信等级", + ) + status = Column( + String(32), + nullable=False, + default="inbox", + comment="来源编译状态", + ) + compiled_markdown = Column(Text, nullable=True, comment="来源编译说明Markdown") + error_message = Column(Text, nullable=True, comment="提取或编译失败原因") + occurred_at = Column(DateTime(timezone=True), nullable=True, comment="资料实际发生时间") + imported_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="来源入箱时间", + ) + compiled_at = Column(DateTime(timezone=True), nullable=True, comment="来源编译完成时间") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="更新时间", + ) + + project = relationship("Project") + author = relationship("User") + page_links = relationship( + "ProjectWikiPageSource", + back_populates="source", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + """返回调试信息。""" + return ( + f"" + ) + + +class ProjectWikiPage(Base): + """项目 Wiki 的长期知识页面。""" + + __tablename__ = "project_wiki_pages" + __table_args__ = ( + UniqueConstraint( + "project_id", + "slug", + name="uq_project_wiki_pages_project_slug", + ), + Index( + "ix_project_wiki_pages_project_type_updated", + "project_id", + "page_type", + "updated_at", + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="Wiki页面ID") + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联项目ID", + ) + slug = Column(String(160), nullable=False, comment="Obsidian页面稳定标识") + page_type = Column(String(32), nullable=False, comment="Wiki页面类型") + title = Column(String(256), nullable=False, comment="Wiki页面标题") + summary = Column(Text, nullable=False, default="", comment="页面检索摘要") + content_markdown = Column(Text, nullable=False, comment="页面Markdown正文") + status = Column(String(32), nullable=False, default="active", comment="页面状态") + revision = Column(Integer, nullable=False, default=1, comment="页面修订号") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="更新时间", + ) + + project = relationship("Project") + source_links = relationship( + "ProjectWikiPageSource", + back_populates="page", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + """返回调试信息。""" + return ( + f"" + ) + + +class ProjectWikiPageSource(Base): + """Wiki 页面与来源快照的追溯关系。""" + + __tablename__ = "project_wiki_page_sources" + __table_args__ = ( + UniqueConstraint( + "page_id", + "source_id", + "relation_type", + name="uq_project_wiki_page_sources_relation", + ), + Index( + "ix_project_wiki_page_sources_source_page", + "source_id", + "page_id", + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="页面来源关系ID") + page_id = Column( + Integer, + ForeignKey("project_wiki_pages.id", ondelete="CASCADE"), + nullable=False, + comment="Wiki页面ID", + ) + source_id = Column( + Integer, + ForeignKey("project_wiki_sources.id", ondelete="CASCADE"), + nullable=False, + comment="Wiki来源数据库ID", + ) + relation_type = Column(String(32), nullable=False, comment="页面与来源关系类型") + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="创建时间", + ) + + page = relationship("ProjectWikiPage", back_populates="source_links") + source = relationship("ProjectWikiSource", back_populates="page_links") + + def __repr__(self) -> str: + """返回调试信息。""" + return ( + f"" + ) + + +class ProjectWikiCompilationRun(Base): + """项目 Wiki 增量编译批次。""" + + __tablename__ = "project_wiki_compilation_runs" + __table_args__ = ( + Index( + "ix_project_wiki_compilation_runs_project_started", + "project_id", + "started_at", + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="Wiki编译批次ID") + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联项目ID", + ) + requested_by_user_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="发起编译用户ID", + ) + trigger = Column(String(32), nullable=False, comment="编译触发方式") + status = Column(String(32), nullable=False, default="running", comment="编译批次状态") + model = Column(String(128), nullable=True, comment="实际调用模型") + source_count = Column(Integer, nullable=False, default=0, comment="领取来源数量") + page_count = Column(Integer, nullable=False, default=0, comment="新增或更新页面次数") + lint_errors_json = Column( + Text, + nullable=False, + default="[]", + comment="Wiki检查错误JSON", + ) + error_message = Column(Text, nullable=True, comment="批次失败原因") + started_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="开始时间", + ) + finished_at = Column(DateTime(timezone=True), nullable=True, comment="结束时间") + + project = relationship("Project") + requested_by = relationship("User") + + def __repr__(self) -> str: + """返回调试信息。""" + return ( + f"" + ) diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index 19ee89a..10a21fa 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -1,6 +1,6 @@ """智能助理路由。""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, BackgroundTasks, Depends from sqlalchemy.orm import Session from app.database import get_db @@ -9,6 +9,8 @@ from app.schemas.agent import ( AgentChatRequest, AgentChatResponse, + AgentOutputTemplateResponse, + AgentPromptTemplateResponse, AgentSessionCreate, AgentSessionDetailResponse, AgentSessionMessageResponse, @@ -70,6 +72,30 @@ def list_agent_skills( return [AgentSkillResponse.model_validate(item) for item in agent_service.list_skills()] +@router.get("/prompt-templates", response_model=list[AgentPromptTemplateResponse], summary="获取系统 Prompt 模板") +def list_agent_prompt_templates( + current_user: User = Depends(get_current_user), +) -> list[AgentPromptTemplateResponse]: + """获取内置系统 Prompt 模板。""" + del current_user + return [ + AgentPromptTemplateResponse.model_validate(item) + for item in agent_service.list_prompt_templates() + ] + + +@router.get("/output-templates", response_model=list[AgentOutputTemplateResponse], summary="获取输出模板") +def list_agent_output_templates( + current_user: User = Depends(get_current_user), +) -> list[AgentOutputTemplateResponse]: + """获取内置输出模板。""" + del current_user + return [ + AgentOutputTemplateResponse.model_validate(item) + for item in agent_service.list_output_templates() + ] + + @router.get("/sessions", response_model=list[AgentSessionResponse], summary="获取会话列表") def list_agent_sessions( db: Session = Depends(get_db), @@ -162,9 +188,20 @@ def update_agent_settings( @router.post("/chat", response_model=AgentChatResponse, summary="智能助理对话") async def chat_with_agent( data: AgentChatRequest, + background_tasks: BackgroundTasks, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> AgentChatResponse: """执行智能助理对话。""" session_id, answer, model, tool_calls = await agent_service.chat_with_agent(db, current_user, data) + session = agent_service.get_session(db, current_user, session_id) + if session.project_id is not None: + from app.services import wiki as wiki_service + + background_tasks.add_task( + wiki_service.compile_project_background, + session.project_id, + current_user.id, + "agent_chat", + ) return AgentChatResponse(session_id=session_id, answer=answer, model=model, tool_calls=tool_calls) diff --git a/backend/app/routers/integration.py b/backend/app/routers/integration.py new file mode 100644 index 0000000..1ec347b --- /dev/null +++ b/backend/app/routers/integration.py @@ -0,0 +1,496 @@ +"""外部自动化集成 API 路由。""" + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.deps import require_integration_api_key +from app.models.project import Project, ProjectIssue, ProjectKnowledgeEntry, ProjectPlan +from app.schemas.project import ( + CriticalPathResponse, + IssueCreate, + IssueResponse, + IssueUpdate, + KnowledgeBatchImportRequest, + KnowledgeBatchImportResponse, + KnowledgeEntryCreate, + KnowledgeEntryResponse, + MilestoneCreate, + MilestoneResponse, + MilestoneUpdate, + PlanCreate, + PlanResponse, + PlanUpdate, + ProjectCreate, + ProjectResponse, + ProjectUpdate, + RequirementCreate, + RequirementResponse, + RequirementUpdate, +) +from app.schemas.weekly_report import ( + WeeklyReportDetailResponse, + WeeklyReportFactBatchCreate, + WeeklyReportFactBatchResponse, + WeeklyReportSummaryResponse, +) +from app.services import project as project_service +from app.services import weekly_report as weekly_report_service + +router = APIRouter( + prefix="/api/integration", + tags=["外部集成"], + dependencies=[Depends(require_integration_api_key)], +) + + +def _serialize_project(project: Project) -> ProjectResponse: + """序列化项目及业务数量。""" + response = ProjectResponse.model_validate(project) + response.milestone_count = len(project.milestones or []) + response.plan_count = len(project.plans or []) + response.requirement_count = len(project.requirements or []) + response.issue_count = len(project.issues or []) + response.cost_count = len(project.cost_records or []) + return response + + +def _serialize_plan(plan: ProjectPlan) -> PlanResponse: + """序列化计划及其前置依赖。""" + response = PlanResponse.model_validate(plan) + response.predecessor_plan_ids = [ + link.predecessor_plan_id for link in plan.predecessor_links + ] + return response + + +def _serialize_issue(issue: ProjectIssue) -> IssueResponse: + """序列化问题,集成接口不展开图片内容。""" + return IssueResponse( + id=issue.id, + project_id=issue.project_id, + title=issue.title, + description=issue.description, + severity=issue.severity, + status=issue.status, + assignee=issue.assignee, + resolution=issue.resolution, + images=[], + created_at=issue.created_at, + updated_at=issue.updated_at, + ) + + +def _serialize_knowledge_entry(entry: ProjectKnowledgeEntry) -> KnowledgeEntryResponse: + """序列化项目知识条目。""" + return KnowledgeEntryResponse( + id=entry.id, + project_id=entry.project_id, + title=entry.title, + source_type=entry.source_type, + content=entry.content, + source_uri=entry.source_uri, + external_id=entry.external_id, + tags=project_service.deserialize_knowledge_tags(entry.tags_json), + occurred_at=entry.occurred_at, + content_hash=entry.content_hash, + created_at=entry.created_at, + updated_at=entry.updated_at, + ) + + +@router.get("/projects", response_model=list[ProjectResponse], summary="查询项目列表") +def list_projects( + keyword: str | None = Query(None, description="项目关键词"), + db: Session = Depends(get_db), +) -> list[ProjectResponse]: + """查询可供外部客户端使用的项目列表。""" + projects = project_service.list_projects(db, keyword=keyword) + return [_serialize_project(project) for project in projects] + + +@router.post( + "/projects", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, + summary="创建项目", +) +def create_project( + data: ProjectCreate, + db: Session = Depends(get_db), +) -> ProjectResponse: + """通过集成接口创建项目。""" + project = project_service.create_project(db, **data.model_dump()) + return _serialize_project(project) + + +@router.get("/projects/{project_id}", response_model=ProjectResponse, summary="查询项目详情") +def get_project( + project_id: int, + db: Session = Depends(get_db), +) -> ProjectResponse: + """查询项目详情和业务数量。""" + return _serialize_project(project_service.get_project(db, project_id)) + + +@router.put("/projects/{project_id}", response_model=ProjectResponse, summary="更新项目") +def update_project( + project_id: int, + data: ProjectUpdate, + db: Session = Depends(get_db), +) -> ProjectResponse: + """通过集成接口更新项目。""" + project = project_service.update_project( + db, + project_id, + data.model_dump(exclude_unset=True), + ) + return _serialize_project(project) + + +@router.get( + "/projects/{project_id}/milestones", + response_model=list[MilestoneResponse], + summary="查询项目里程碑", +) +def list_milestones( + project_id: int, + db: Session = Depends(get_db), +) -> list[MilestoneResponse]: + """查询项目里程碑。""" + return [ + MilestoneResponse.model_validate(item) + for item in project_service.list_milestones(db, project_id) + ] + + +@router.post( + "/projects/{project_id}/milestones", + response_model=MilestoneResponse, + status_code=status.HTTP_201_CREATED, + summary="创建项目里程碑", +) +def create_milestone( + project_id: int, + data: MilestoneCreate, + db: Session = Depends(get_db), +) -> MilestoneResponse: + """通过集成接口创建项目里程碑。""" + milestone = project_service.create_milestone(db, project_id, **data.model_dump()) + return MilestoneResponse.model_validate(milestone) + + +@router.put( + "/milestones/{milestone_id}", + response_model=MilestoneResponse, + summary="更新项目里程碑", +) +def update_milestone( + milestone_id: int, + data: MilestoneUpdate, + db: Session = Depends(get_db), +) -> MilestoneResponse: + """通过集成接口更新项目里程碑。""" + milestone = project_service.update_milestone( + db, + milestone_id, + data.model_dump(exclude_unset=True), + ) + return MilestoneResponse.model_validate(milestone) + + +@router.get( + "/projects/{project_id}/plans", + response_model=list[PlanResponse], + summary="查询项目计划", +) +def list_plans( + project_id: int, + db: Session = Depends(get_db), +) -> list[PlanResponse]: + """查询项目计划。""" + return [_serialize_plan(item) for item in project_service.list_plans(db, project_id)] + + +@router.post( + "/projects/{project_id}/plans", + response_model=PlanResponse, + status_code=status.HTTP_201_CREATED, + summary="创建项目计划", +) +def create_plan( + project_id: int, + data: PlanCreate, + db: Session = Depends(get_db), +) -> PlanResponse: + """通过集成接口创建项目计划。""" + plan = project_service.create_plan(db, project_id=project_id, **data.model_dump()) + return _serialize_plan(plan) + + +@router.put("/plans/{plan_id}", response_model=PlanResponse, summary="更新项目计划") +def update_plan( + plan_id: int, + data: PlanUpdate, + db: Session = Depends(get_db), +) -> PlanResponse: + """通过集成接口更新项目计划。""" + plan = project_service.update_plan( + db, + plan_id, + data.model_dump(exclude_unset=True), + ) + return _serialize_plan(plan) + + +@router.get( + "/projects/{project_id}/critical-path", + response_model=CriticalPathResponse, + summary="计算项目关键路径", +) +def get_critical_path( + project_id: int, + db: Session = Depends(get_db), +) -> CriticalPathResponse: + """通过集成接口读取关键路径分析结果。""" + return CriticalPathResponse(**project_service.calculate_critical_path(db, project_id)) + + +@router.get( + "/projects/{project_id}/requirements", + response_model=list[RequirementResponse], + summary="查询项目需求", +) +def list_requirements( + project_id: int, + db: Session = Depends(get_db), +) -> list[RequirementResponse]: + """查询项目需求。""" + return [ + RequirementResponse.model_validate(item) + for item in project_service.list_requirements(db, project_id) + ] + + +@router.post( + "/projects/{project_id}/requirements", + response_model=RequirementResponse, + status_code=status.HTTP_201_CREATED, + summary="创建项目需求", +) +def create_requirement( + project_id: int, + data: RequirementCreate, + db: Session = Depends(get_db), +) -> RequirementResponse: + """通过集成接口创建项目需求。""" + requirement = project_service.create_requirement(db, project_id, **data.model_dump()) + return RequirementResponse.model_validate(requirement) + + +@router.put( + "/requirements/{requirement_id}", + response_model=RequirementResponse, + summary="更新项目需求", +) +def update_requirement( + requirement_id: int, + data: RequirementUpdate, + db: Session = Depends(get_db), +) -> RequirementResponse: + """通过集成接口更新项目需求。""" + requirement = project_service.update_requirement( + db, + requirement_id, + data.model_dump(exclude_unset=True), + ) + return RequirementResponse.model_validate(requirement) + + +@router.get( + "/projects/{project_id}/issues", + response_model=list[IssueResponse], + summary="查询项目问题", +) +def list_issues( + project_id: int, + db: Session = Depends(get_db), +) -> list[IssueResponse]: + """查询项目问题。""" + return [_serialize_issue(item) for item in project_service.list_issues(db, project_id)] + + +@router.post( + "/projects/{project_id}/issues", + response_model=IssueResponse, + status_code=status.HTTP_201_CREATED, + summary="创建项目问题", +) +def create_issue( + project_id: int, + data: IssueCreate, + db: Session = Depends(get_db), +) -> IssueResponse: + """通过集成接口创建项目问题。""" + issue = project_service.create_issue(db, project_id, **data.model_dump()) + return _serialize_issue(issue) + + +@router.put("/issues/{issue_id}", response_model=IssueResponse, summary="更新项目问题") +def update_issue( + issue_id: int, + data: IssueUpdate, + db: Session = Depends(get_db), +) -> IssueResponse: + """通过集成接口更新项目问题。""" + issue = project_service.update_issue( + db, + issue_id, + data.model_dump(exclude_unset=True), + ) + return _serialize_issue(issue) + + +@router.get( + "/projects/{project_id}/knowledge", + response_model=list[KnowledgeEntryResponse], + summary="检索项目知识库", +) +def list_knowledge_entries( + project_id: int, + query: str | None = Query(None, description="标题、正文或标签关键词"), + source_type: str | None = Query( + None, + pattern="^(meeting_notes|development_detail|chat_record|document|other)$", + description="来源类型", + ), + occurred_from: datetime | None = Query(None, description="资料发生时间起点"), + occurred_to: datetime | None = Query(None, description="资料发生时间终点"), + limit: int = Query(50, ge=1, le=100, description="最大返回数量"), + db: Session = Depends(get_db), +) -> list[KnowledgeEntryResponse]: + """通过集成接口检索项目知识条目。""" + entries = project_service.list_knowledge_entries( + db, + project_id, + query=query, + source_type=source_type, + occurred_from=occurred_from, + occurred_to=occurred_to, + limit=limit, + ) + return [_serialize_knowledge_entry(entry) for entry in entries] + + +@router.post( + "/projects/{project_id}/knowledge", + response_model=KnowledgeEntryResponse, + summary="幂等导入项目知识条目", +) +def upsert_knowledge_entry( + project_id: int, + data: KnowledgeEntryCreate, + db: Session = Depends(get_db), +) -> KnowledgeEntryResponse: + """创建知识条目,相同外部ID存在时更新原记录。""" + entry, _ = project_service.upsert_knowledge_entry( + db, + project_id, + **data.model_dump(), + ) + return _serialize_knowledge_entry(entry) + + +@router.post( + "/projects/{project_id}/knowledge/batch", + response_model=KnowledgeBatchImportResponse, + summary="批量幂等导入项目知识条目", +) +def batch_upsert_knowledge_entries( + project_id: int, + data: KnowledgeBatchImportRequest, + db: Session = Depends(get_db), +) -> KnowledgeBatchImportResponse: + """批量创建或更新项目知识条目。""" + created_count = 0 + updated_count = 0 + responses: list[KnowledgeEntryResponse] = [] + for item in data.entries: + entry, created = project_service.upsert_knowledge_entry( + db, + project_id, + **item.model_dump(), + ) + if created: + created_count += 1 + else: + updated_count += 1 + responses.append(_serialize_knowledge_entry(entry)) + + return KnowledgeBatchImportResponse( + created_count=created_count, + updated_count=updated_count, + entries=responses, + ) + + +@router.get( + "/projects/{project_id}/weekly-reports", + response_model=list[WeeklyReportSummaryResponse], + summary="查询项目周报", +) +def list_weekly_reports( + project_id: int, + db: Session = Depends(get_db), +) -> list[WeeklyReportSummaryResponse]: + """通过集成接口读取项目周报列表。""" + reports = weekly_report_service.list_reports(db, project_id) + return [ + WeeklyReportSummaryResponse.model_validate( + weekly_report_service.serialize_report(report, include_detail=False) + ) + for report in reports + ] + + +@router.get( + "/weekly-reports/{report_id}", + response_model=WeeklyReportDetailResponse, + summary="读取周报详情", +) +def get_weekly_report( + report_id: int, + db: Session = Depends(get_db), +) -> WeeklyReportDetailResponse: + """通过集成接口读取事实、证据和已归档版本。""" + report = weekly_report_service.get_report(db, report_id) + return WeeklyReportDetailResponse.model_validate( + weekly_report_service.serialize_report(report, include_detail=True) + ) + + +@router.post( + "/projects/{project_id}/weekly-reports/{report_id}/facts/batch", + response_model=WeeklyReportFactBatchResponse, + summary="批量导入周报候选事实", +) +def batch_upsert_weekly_report_facts( + project_id: int, + report_id: int, + data: WeeklyReportFactBatchCreate, + db: Session = Depends(get_db), +) -> WeeklyReportFactBatchResponse: + """批量导入候选事实,所有记录保持待确认状态。""" + created_count, updated_count, fact_ids = weekly_report_service.batch_upsert_external_facts( + db, + project_id, + report_id, + data.facts, + ) + return WeeklyReportFactBatchResponse( + created_count=created_count, + updated_count=updated_count, + fact_ids=fact_ids, + ) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index bbc55b1..9b4d9a9 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -1,27 +1,36 @@ """项目管理路由。""" +from datetime import date, datetime + from urllib.parse import quote -from fastapi import APIRouter, Depends, File, Query, UploadFile, status +from fastapi import APIRouter, BackgroundTasks, Depends, File, Query, UploadFile, status from fastapi.responses import Response from sqlalchemy.orm import Session from app.database import get_db from app.deps import get_current_user, require_manager_or_admin +from app.models.project import ProjectKnowledgeEntry from app.models.user import User from app.schemas.project import ( CostRecordCreate, CostRecordListResponse, CostRecordResponse, CostRecordUpdate, + CriticalPathResponse, IssueCreate, IssueImageResponse, IssueResponse, IssueUpdate, + KnowledgeEntryCreate, + KnowledgeEntryResponse, + KnowledgeEntryUpdate, MilestoneCreate, MilestoneResponse, MilestoneUpdate, PlanCreate, + PlanDependencyCreate, + PlanDependencyResponse, PlanImportResponse, PlanResponse, PlanUpdate, @@ -29,12 +38,14 @@ ProjectDocumentListResponse, ProjectCreate, ProjectResponse, + ProjectRiskReportResponse, ProjectUpdate, RequirementCreate, RequirementResponse, RequirementUpdate, ) from app.services import project as project_service +from app.services import risk as risk_service router = APIRouter(prefix="/api/projects", tags=["项目管理"]) @@ -68,6 +79,33 @@ def _serialize_issue(issue) -> IssueResponse: ) +def _serialize_plan(plan) -> PlanResponse: + """序列化计划及其前置依赖。""" + response = PlanResponse.model_validate(plan) + response.predecessor_plan_ids = [ + link.predecessor_plan_id for link in plan.predecessor_links + ] + return response + + +def _serialize_knowledge_entry(entry: ProjectKnowledgeEntry) -> KnowledgeEntryResponse: + """序列化项目知识条目。""" + return KnowledgeEntryResponse( + id=entry.id, + project_id=entry.project_id, + title=entry.title, + source_type=entry.source_type, + content=entry.content, + source_uri=entry.source_uri, + external_id=entry.external_id, + tags=project_service.deserialize_knowledge_tags(entry.tags_json), + occurred_at=entry.occurred_at, + content_hash=entry.content_hash, + created_at=entry.created_at, + updated_at=entry.updated_at, + ) + + @router.get( "", response_model=list[ProjectResponse], @@ -164,6 +202,24 @@ def get_project( return response +@router.get( + "/{project_id}/risks", + response_model=ProjectRiskReportResponse, + summary="获取项目风险预警", + description="按结构化项目数据执行可解释的风险规则,供页面和 Agent 共用", +) +def get_project_risks( + project_id: int, + as_of: date | None = Query(None, description="评估日期,默认今天"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ProjectRiskReportResponse: + """获取项目风险预警报告。""" + del current_user + report = risk_service.get_project_risk_report(db, project_id, as_of) + return ProjectRiskReportResponse.model_validate(report) + + @router.put( "/{project_id}", response_model=ProjectResponse, @@ -298,7 +354,7 @@ def list_plans( """获取计划列表。""" del current_user plans = project_service.list_plans(db, project_id) - return [PlanResponse.model_validate(item) for item in plans] + return [_serialize_plan(item) for item in plans] @router.post( @@ -318,11 +374,15 @@ def create_plan( plan = project_service.create_plan( db, project_id=project_id, + task_code=data.task_code, phase_name=data.phase_name, primary_task=data.primary_task, secondary_task=data.secondary_task, dependency=data.dependency, duration=data.duration, + duration_days=data.duration_days, + is_summary=data.is_summary, + predecessor_plan_ids=data.predecessor_plan_ids, progress_pct=data.progress_pct, description=data.description, planned_start=data.planned_start, @@ -332,7 +392,48 @@ def create_plan( status=data.status, assignee=data.assignee, ) - return PlanResponse.model_validate(plan) + return _serialize_plan(plan) + + +@router.get( + "/{project_id}/plans/critical-path", + response_model=CriticalPathResponse, + summary="计算项目计划关键路径", +) +def get_critical_path( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> CriticalPathResponse: + """计算项目计划关键路径。""" + del current_user + return CriticalPathResponse(**project_service.calculate_critical_path(db, project_id)) + + +@router.post( + "/{project_id}/plans/{plan_id}/dependencies", + response_model=PlanDependencyResponse, + status_code=status.HTTP_201_CREATED, + summary="创建计划依赖", +) +def create_plan_dependency( + project_id: int, + plan_id: int, + data: PlanDependencyCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> PlanDependencyResponse: + """创建计划依赖。""" + del current_user + dependency = project_service.create_plan_dependency( + db, + project_id=project_id, + successor_plan_id=plan_id, + predecessor_plan_id=data.predecessor_plan_id, + dependency_type=data.dependency_type, + lag_days=data.lag_days, + ) + return PlanDependencyResponse.model_validate(dependency) @router.post( @@ -354,7 +455,7 @@ def import_plans( created_count=result["created_count"], skipped_count=result["skipped_count"], errors=result["errors"], - plans=[PlanResponse.model_validate(plan) for plan in result["plans"]], + plans=[_serialize_plan(plan) for plan in result["plans"]], ) @@ -398,7 +499,22 @@ def update_plan( plan_id=plan_id, update_data=data.model_dump(exclude_unset=True), ) - return PlanResponse.model_validate(plan) + return _serialize_plan(plan) + + +@router.delete( + "/plan-dependencies/{dependency_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除计划依赖", +) +def delete_plan_dependency( + dependency_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> None: + """删除计划依赖。""" + del current_user + project_service.delete_plan_dependency(db, dependency_id) @router.delete( @@ -574,6 +690,115 @@ def delete_issue( project_service.delete_issue(db, issue_id) +@router.get( + "/{project_id}/knowledge", + response_model=list[KnowledgeEntryResponse], + summary="检索项目知识库", +) +def list_knowledge_entries( + project_id: int, + query: str | None = Query(None, description="标题、正文或标签关键词"), + source_type: str | None = Query( + None, + pattern="^(meeting_notes|development_detail|chat_record|document|other)$", + description="来源类型", + ), + occurred_from: datetime | None = Query(None, description="资料发生时间起点"), + occurred_to: datetime | None = Query(None, description="资料发生时间终点"), + limit: int = Query(50, ge=1, le=100, description="最大返回数量"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[KnowledgeEntryResponse]: + """按项目检索知识条目。""" + del current_user + entries = project_service.list_knowledge_entries( + db, + project_id, + query=query, + source_type=source_type, + occurred_from=occurred_from, + occurred_to=occurred_to, + limit=limit, + ) + return [_serialize_knowledge_entry(entry) for entry in entries] + + +@router.post( + "/{project_id}/knowledge", + response_model=KnowledgeEntryResponse, + status_code=status.HTTP_201_CREATED, + summary="导入项目知识条目", +) +def create_knowledge_entry( + project_id: int, + data: KnowledgeEntryCreate, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> KnowledgeEntryResponse: + """创建知识条目,相同外部ID存在时更新原记录。""" + entry, _ = project_service.upsert_knowledge_entry( + db, + project_id, + **data.model_dump(), + author_user_id=current_user.id, + ) + from app.services import wiki as wiki_service + + background_tasks.add_task( + wiki_service.compile_project_background, + project_id, + current_user.id, + "knowledge_entry", + ) + return _serialize_knowledge_entry(entry) + + +@router.put( + "/knowledge/{entry_id}", + response_model=KnowledgeEntryResponse, + summary="更新项目知识条目", +) +def update_knowledge_entry( + entry_id: int, + data: KnowledgeEntryUpdate, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> KnowledgeEntryResponse: + """更新项目知识条目。""" + entry = project_service.update_knowledge_entry( + db, + entry_id, + data.model_dump(exclude_unset=True), + author_user_id=current_user.id, + ) + from app.services import wiki as wiki_service + + background_tasks.add_task( + wiki_service.compile_project_background, + entry.project_id, + current_user.id, + "knowledge_entry_update", + ) + return _serialize_knowledge_entry(entry) + + +@router.delete( + "/knowledge/{entry_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除项目知识条目", +) +def delete_knowledge_entry( + entry_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> None: + """删除项目知识条目。""" + del current_user + project_service.delete_knowledge_entry(db, entry_id) + + @router.get( "/{project_id}/documents", response_model=ProjectDocumentListResponse, @@ -597,14 +822,28 @@ def get_project_documents( ) def upload_project_documents( project_id: int, + background_tasks: BackgroundTasks, directory: str | None = Query(None, description="目标目录"), files: list[UploadFile] = File(...), db: Session = Depends(get_db), current_user: User = Depends(require_manager_or_admin), ) -> list[ProjectDocumentFileResponse]: """上传项目文档。""" - del current_user - documents = project_service.upload_project_documents(db, project_id, files, directory) + documents = project_service.upload_project_documents( + db, + project_id, + files, + directory, + author_user_id=current_user.id, + ) + from app.services import wiki as wiki_service + + background_tasks.add_task( + wiki_service.compile_project_background, + project_id, + current_user.id, + "document_upload", + ) return [ ProjectDocumentFileResponse( id=document.id, diff --git a/backend/app/routers/weekly_reports.py b/backend/app/routers/weekly_reports.py new file mode 100644 index 0000000..d2a28d0 --- /dev/null +++ b/backend/app/routers/weekly_reports.py @@ -0,0 +1,260 @@ +"""项目周报路由。""" + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.deps import get_current_user, require_manager_or_admin +from app.models.user import User +from app.schemas.weekly_report import ( + WeeklyReportApproveRequest, + WeeklyReportCollectResponse, + WeeklyReportCreate, + WeeklyReportDetailResponse, + WeeklyReportDraftUpdate, + WeeklyReportFactCreate, + WeeklyReportFactResponse, + WeeklyReportFactUpdate, + WeeklyReportGenerateRequest, + WeeklyReportSummaryResponse, + WeeklyReportValidationResponse, + WeeklyReportVersionResponse, +) +from app.services import weekly_report as weekly_report_service + +router = APIRouter(prefix="/api/projects", tags=["项目周报"]) + + +@router.get( + "/{project_id}/weekly-reports", + response_model=list[WeeklyReportSummaryResponse], + summary="获取项目周报列表", +) +def list_weekly_reports( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[WeeklyReportSummaryResponse]: + """获取当前项目的周报周期。""" + del current_user + reports = weekly_report_service.list_reports(db, project_id) + return [ + WeeklyReportSummaryResponse.model_validate( + weekly_report_service.serialize_report(report, include_detail=False) + ) + for report in reports + ] + + +@router.post( + "/{project_id}/weekly-reports", + response_model=WeeklyReportDetailResponse, + status_code=status.HTTP_201_CREATED, + summary="创建项目周报", +) +def create_weekly_report( + project_id: int, + data: WeeklyReportCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WeeklyReportDetailResponse: + """创建唯一的项目周报周期。""" + report = weekly_report_service.create_report(db, project_id, current_user, data) + return WeeklyReportDetailResponse.model_validate( + weekly_report_service.serialize_report(report, include_detail=True) + ) + + +@router.get( + "/weekly-reports/{report_id}", + response_model=WeeklyReportDetailResponse, + summary="获取周报详情", +) +def get_weekly_report( + report_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WeeklyReportDetailResponse: + """读取周报事实、证据和版本。""" + del current_user + report = weekly_report_service.get_report(db, report_id) + return WeeklyReportDetailResponse.model_validate( + weekly_report_service.serialize_report(report, include_detail=True) + ) + + +@router.post( + "/weekly-reports/{report_id}/collect", + response_model=WeeklyReportCollectResponse, + summary="采集周报候选事实", +) +def collect_weekly_report_inputs( + report_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WeeklyReportCollectResponse: + """按周期幂等采集候选事实和来源快照。""" + report, coverage, created_count, updated_count = ( + weekly_report_service.collect_report_inputs(db, report_id, current_user) + ) + return WeeklyReportCollectResponse.model_validate( + { + "created_count": created_count, + "updated_count": updated_count, + "coverage": coverage, + "report": weekly_report_service.serialize_report(report, include_detail=True), + } + ) + + +@router.get( + "/weekly-report-facts/{fact_id}", + response_model=WeeklyReportFactResponse, + summary="获取周报事实", +) +def get_weekly_report_fact( + fact_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WeeklyReportFactResponse: + """读取单条事实及其证据。""" + del current_user + fact = weekly_report_service.get_fact(db, fact_id) + return WeeklyReportFactResponse.model_validate(weekly_report_service.serialize_fact(fact)) + + +@router.post( + "/weekly-reports/{report_id}/facts", + response_model=WeeklyReportFactResponse, + status_code=status.HTTP_201_CREATED, + summary="补充周报候选事实", +) +def create_weekly_report_fact( + report_id: int, + data: WeeklyReportFactCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WeeklyReportFactResponse: + """人工补充待确认事实,普通成员不能直接核验。""" + fact = weekly_report_service.create_manual_fact(db, report_id, current_user, data) + return WeeklyReportFactResponse.model_validate(weekly_report_service.serialize_fact(fact)) + + +@router.put( + "/weekly-report-facts/{fact_id}", + response_model=WeeklyReportFactResponse, + summary="审核周报事实", +) +def update_weekly_report_fact( + fact_id: int, + data: WeeklyReportFactUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WeeklyReportFactResponse: + """由项目经理或管理员审核事实。""" + fact = weekly_report_service.update_fact( + db, + fact_id, + current_user, + data.model_dump(exclude_unset=True), + ) + return WeeklyReportFactResponse.model_validate(weekly_report_service.serialize_fact(fact)) + + +@router.get( + "/weekly-reports/{report_id}/validate", + response_model=WeeklyReportValidationResponse, + summary="校验周报", +) +def validate_weekly_report( + report_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WeeklyReportValidationResponse: + """返回当前周报的阻断项和告警项。""" + del current_user + return WeeklyReportValidationResponse.model_validate( + weekly_report_service.validate_report(db, report_id) + ) + + +@router.post( + "/weekly-reports/{report_id}/generate-draft", + response_model=WeeklyReportVersionResponse, + summary="生成AI周报草稿", +) +async def generate_weekly_report_draft( + report_id: int, + data: WeeklyReportGenerateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WeeklyReportVersionResponse: + """只使用已核验事实生成结构化草稿。""" + version = await weekly_report_service.generate_report_draft( + db, + report_id, + current_user, + requested_model=data.model, + temporary_api_key=data.api_key, + ) + return WeeklyReportVersionResponse.model_validate( + weekly_report_service.serialize_version(version) + ) + + +@router.put( + "/weekly-report-versions/{version_id}", + response_model=WeeklyReportVersionResponse, + summary="保存人工修订草稿", +) +def save_weekly_report_draft( + version_id: int, + data: WeeklyReportDraftUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WeeklyReportVersionResponse: + """把人工修订保存成新的草稿版本。""" + version = weekly_report_service.save_draft_version( + db, + version_id, + current_user, + data.content_markdown, + ) + return WeeklyReportVersionResponse.model_validate( + weekly_report_service.serialize_version(version) + ) + + +@router.post( + "/weekly-reports/{report_id}/approve", + response_model=WeeklyReportDetailResponse, + summary="批准周报终稿", +) +def approve_weekly_report( + report_id: int, + data: WeeklyReportApproveRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WeeklyReportDetailResponse: + """执行最终校验并由人工批准正式版本。""" + report = weekly_report_service.approve_report(db, report_id, current_user, data) + return WeeklyReportDetailResponse.model_validate( + weekly_report_service.serialize_report(report, include_detail=True) + ) + + +@router.post( + "/weekly-reports/{report_id}/archive", + response_model=WeeklyReportDetailResponse, + summary="归档周报", +) +def archive_weekly_report( + report_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WeeklyReportDetailResponse: + """归档已经批准且不可再修改的周报。""" + report = weekly_report_service.archive_report(db, report_id, current_user) + return WeeklyReportDetailResponse.model_validate( + weekly_report_service.serialize_report(report, include_detail=True) + ) diff --git a/backend/app/routers/wiki.py b/backend/app/routers/wiki.py new file mode 100644 index 0000000..ef8a313 --- /dev/null +++ b/backend/app/routers/wiki.py @@ -0,0 +1,245 @@ +"""项目 LLM Wiki 路由。""" + +from urllib.parse import quote + +from fastapi import APIRouter, BackgroundTasks, Depends, Query +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from app.database import get_db +from app.deps import get_current_user, require_manager_or_admin +from app.models.user import User +from app.schemas.wiki import ( + ProjectWikiOverviewResponse, + WikiCompilationRunResponse, + WikiCompileRequest, + WikiPageResponse, + WikiSourceResponse, +) +from app.services import wiki as wiki_service + + +router = APIRouter(prefix="/api/projects", tags=["项目知识库"]) + + +@router.get( + "/{project_id}/wiki", + response_model=ProjectWikiOverviewResponse, + summary="获取项目 Wiki 总览", +) +def get_project_wiki( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ProjectWikiOverviewResponse: + """返回来源、长期页面、状态统计和最近编译批次。""" + del current_user + return wiki_service.get_wiki_overview(db, project_id) + + +@router.get( + "/{project_id}/wiki/sources", + response_model=list[WikiSourceResponse], + summary="检索项目 Wiki 来源", +) +def list_project_wiki_sources( + project_id: int, + query: str | None = Query(None, description="标题、原文或编译说明关键词"), + source_status: str | None = Query( + None, + pattern="^(inbox|compiling|compiled|failed|unsupported|superseded)$", + description="来源状态", + ), + source_type: str | None = Query(None, description="来源类型"), + limit: int = Query(100, ge=1, le=200), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[WikiSourceResponse]: + """按条件检索不可变来源快照。""" + del current_user + sources = wiki_service.list_wiki_sources( + db, + project_id, + query=query, + source_status=source_status, + source_type=source_type, + limit=limit, + ) + return [wiki_service.serialize_wiki_source(source) for source in sources] + + +@router.get( + "/{project_id}/wiki/pages", + response_model=list[WikiPageResponse], + summary="检索项目 Wiki 页面", +) +def list_project_wiki_pages( + project_id: int, + query: str | None = Query(None, description="标题、摘要或正文关键词"), + page_type: str | None = Query( + None, + pattern="^(topic|concept|synthesis|idea)$", + description="页面类型", + ), + limit: int = Query(100, ge=1, le=200), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[WikiPageResponse]: + """按条件检索长期 Wiki 页面。""" + del current_user + pages = wiki_service.list_wiki_pages( + db, + project_id, + query=query, + page_type=page_type, + limit=limit, + ) + return [wiki_service.serialize_wiki_page(page) for page in pages] + + +@router.get( + "/{project_id}/wiki/pages/{slug}", + response_model=WikiPageResponse, + summary="获取项目 Wiki 页面", +) +def get_project_wiki_page( + project_id: int, + slug: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WikiPageResponse: + """获取单个 Wiki 页面及其来源追溯。""" + del current_user + page = wiki_service.get_wiki_page(db, project_id, slug) + return wiki_service.serialize_wiki_page(page) + + +@router.get( + "/{project_id}/wiki/runs", + response_model=list[WikiCompilationRunResponse], + summary="获取项目 Wiki 编译记录", +) +def list_project_wiki_runs( + project_id: int, + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[WikiCompilationRunResponse]: + """获取最近的持久编译批次。""" + del current_user + runs = wiki_service.list_compilation_runs(db, project_id, limit=limit) + return [wiki_service.serialize_compilation_run(run) for run in runs] + + +@router.get( + "/{project_id}/wiki/runs/{run_id}", + response_model=WikiCompilationRunResponse, + summary="获取项目 Wiki 编译批次", +) +def get_project_wiki_run( + project_id: int, + run_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WikiCompilationRunResponse: + """获取指定编译批次状态。""" + del current_user + run = wiki_service.get_compilation_run(db, project_id, run_id) + return wiki_service.serialize_compilation_run(run) + + +@router.post( + "/{project_id}/wiki/compile", + response_model=WikiCompilationRunResponse, + summary="编译项目 Wiki 收件箱", +) +async def compile_project_wiki( + project_id: int, + data: WikiCompileRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WikiCompilationRunResponse: + """使用当前用户已保存的模型配置编译待处理来源。""" + run = await wiki_service.compile_project_inbox( + db, + project_id, + current_user, + trigger="manual", + source_ids=data.source_ids, + include_failed=data.include_failed, + ) + return wiki_service.serialize_compilation_run(run) + + +@router.post( + "/{project_id}/wiki/retry", + response_model=WikiCompilationRunResponse, + summary="重试失败的项目 Wiki 来源", +) +async def retry_project_wiki( + project_id: int, + data: WikiCompileRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> WikiCompilationRunResponse: + """恢复失败来源并立即发起一次编译。""" + wiki_service.reset_failed_sources( + db, + project_id, + source_ids=data.source_ids, + ) + run = await wiki_service.compile_project_inbox( + db, + project_id, + current_user, + trigger="manual_retry", + source_ids=data.source_ids, + include_failed=False, + ) + return wiki_service.serialize_compilation_run(run) + + +@router.post( + "/{project_id}/wiki/backfill", + response_model=dict[str, int], + summary="补录项目已有文档和知识条目", +) +def backfill_project_wiki( + project_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin), +) -> dict[str, int]: + """幂等补录已有资料,不推断历史聊天的项目归属。""" + result = wiki_service.backfill_project_sources( + db, + project_id, + author_user_id=current_user.id, + ) + background_tasks.add_task( + wiki_service.compile_project_background, + project_id, + current_user.id, + "backfill", + ) + return result + + +@router.get( + "/{project_id}/wiki/export", + summary="导出 Obsidian 项目知识库", +) +def export_project_wiki( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Response: + """把数据库中的项目 Wiki 重新投影并下载为 ZIP。""" + del current_user + archive = wiki_service.build_project_vault_zip(db, project_id) + filename = quote(f"tinypm-project-{project_id}-wiki.zip") + return Response( + content=archive, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"}, + ) diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index 5bae8a1..1807dfb 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -26,7 +26,35 @@ class AgentSkillResponse(BaseModel): id: str = Field(..., description="技能ID") name: str = Field(..., description="技能名称") + category: str = Field(..., description="技能分类") description: str = Field(..., description="技能描述") + requires_project: bool = Field(..., description="是否需要项目上下文") + can_write: bool = Field(..., description="是否允许写入项目数据") + tool_names: list[str] = Field(default_factory=list, description="可用 MCP 工具") + write_tool_names: list[str] = Field(default_factory=list, description="写入类 MCP 工具") + prompt_template_id: str = Field(..., description="推荐系统 Prompt 模板ID") + prompt_template_name: str = Field(..., description="推荐系统 Prompt 模板名称") + output_template_id: str = Field(..., description="输出模板ID") + output_template_name: str = Field(..., description="输出模板名称") + quick_prompts: list[dict[str, str]] = Field(default_factory=list, description="快捷动作") + + +class AgentPromptTemplateResponse(BaseModel): + """系统 Prompt 模板响应体。""" + + id: str = Field(..., description="模板ID") + name: str = Field(..., description="模板名称") + description: str = Field(..., description="模板描述") + content: str = Field(..., description="模板内容") + + +class AgentOutputTemplateResponse(BaseModel): + """输出模板响应体。""" + + id: str = Field(..., description="模板ID") + name: str = Field(..., description="模板名称") + description: str = Field(..., description="模板描述") + content: str = Field(..., description="模板内容") class AgentToolCallSummary(BaseModel): diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 0b323bd..27bbc58 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -2,8 +2,9 @@ from datetime import date, datetime from decimal import Decimal +from typing import Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator def _validate_git_url(value: str | None) -> str | None: @@ -144,11 +145,15 @@ class Config: class PlanCreate(BaseModel): """创建计划请求体。""" + task_code: str | None = Field(None, max_length=64, description="任务编码") phase_name: str = Field(..., min_length=1, max_length=256, description="阶段名称") primary_task: str | None = Field(None, max_length=256, description="一级任务") secondary_task: str | None = Field(None, max_length=256, description="二级任务") dependency: str | None = Field(None, max_length=256, description="依赖项") duration: str | None = Field(None, max_length=32, description="工期") + duration_days: int | None = Field(None, ge=0, description="可计算工期天数") + is_summary: bool = Field(default=False, description="是否汇总任务") + predecessor_plan_ids: list[int] = Field(default_factory=list, description="前置计划ID列表") progress_pct: int = Field(default=0, ge=0, le=100, description="当前进度百分比") description: str | None = Field(None, description="描述") planned_start: date | None = Field(None, description="计划开始") @@ -162,11 +167,15 @@ class PlanCreate(BaseModel): class PlanUpdate(BaseModel): """更新计划请求体。""" + task_code: str | None = Field(None, max_length=64, description="任务编码") phase_name: str | None = Field(None, min_length=1, max_length=256, description="阶段名称") primary_task: str | None = Field(None, max_length=256, description="一级任务") secondary_task: str | None = Field(None, max_length=256, description="二级任务") dependency: str | None = Field(None, max_length=256, description="依赖项") duration: str | None = Field(None, max_length=32, description="工期") + duration_days: int | None = Field(None, ge=0, description="可计算工期天数") + is_summary: bool | None = Field(None, description="是否汇总任务") + predecessor_plan_ids: list[int] | None = Field(None, description="前置计划ID列表") progress_pct: int | None = Field(None, ge=0, le=100, description="当前进度百分比") description: str | None = Field(None, description="描述") planned_start: date | None = Field(None, description="计划开始") @@ -182,11 +191,15 @@ class PlanResponse(BaseModel): id: int project_id: int + task_code: str | None = None phase_name: str primary_task: str | None = None secondary_task: str | None = None dependency: str | None = None duration: str | None = None + duration_days: int | None = None + is_summary: bool = False + predecessor_plan_ids: list[int] = Field(default_factory=list, description="前置计划ID列表") progress_pct: int = 0 description: str | None = None planned_start: date | None = None @@ -289,6 +302,98 @@ class Config: from_attributes = True +class PlanDependencyCreate(BaseModel): + """创建计划依赖请求体。""" + + predecessor_plan_id: int = Field(..., description="前置计划ID") + dependency_type: str = Field(default="FS", max_length=8, description="依赖类型") + lag_days: int = Field(default=0, description="延迟天数") + + +class PlanDependencyResponse(BaseModel): + """计划依赖响应体。""" + + id: int + project_id: int + predecessor_plan_id: int + successor_plan_id: int + dependency_type: str + lag_days: int + + class Config: + """Pydantic 配置。""" + + from_attributes = True + + +class CriticalPathEdgeResponse(BaseModel): + """关键路径依赖边响应体。""" + + predecessor_plan_id: int + successor_plan_id: int + + +class CriticalPathItemResponse(BaseModel): + """关键路径任务计算结果。""" + + plan_id: int + task_code: str + name: str + duration_days: int | None = None + early_start: int | None = None + early_finish: int | None = None + late_start: int | None = None + late_finish: int | None = None + total_float: int | None = None + is_critical: bool = False + predecessor_plan_ids: list[int] = Field(default_factory=list) + successor_plan_ids: list[int] = Field(default_factory=list) + + +class CriticalPathResponse(BaseModel): + """关键路径分析响应体。""" + + project_id: int + status: str + project_duration_days: int + critical_plan_ids: list[int] = Field(default_factory=list) + critical_edges: list[CriticalPathEdgeResponse] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + items: list[CriticalPathItemResponse] = Field(default_factory=list) + + +class ProjectRiskItemResponse(BaseModel): + """项目风险预警项。""" + + code: str + category: str + level: Literal["高", "中", "低"] + title: str + description: str + evidence: list[str] = Field(default_factory=list) + recommendation: str + entity_type: Literal["project", "plan", "milestone", "requirement", "issue"] + entity_id: int + due_date: date | None = None + days_to_due: int | None = None + + +class ProjectRiskReportResponse(BaseModel): + """项目风险预警报告。""" + + project_id: int + project_name: str + as_of: date + rules_version: str + overall_level: Literal["高", "中", "低"] + score: int = Field(..., ge=0, le=100) + total_count: int = Field(..., ge=0) + high_count: int = Field(..., ge=0) + medium_count: int = Field(..., ge=0) + low_count: int = Field(..., ge=0) + items: list[ProjectRiskItemResponse] = Field(default_factory=list) + + class PlanImportResponse(BaseModel): """计划导入响应体。""" @@ -412,3 +517,96 @@ class ProjectDocumentListResponse(BaseModel): project_id: int = Field(..., description="项目ID") files: list[ProjectDocumentFileResponse] = Field(default_factory=list, description="文档文件列表") + + +KnowledgeSourceType = Literal[ + "meeting_notes", + "development_detail", + "chat_record", + "document", + "other", +] + + +class KnowledgeEntryCreate(BaseModel): + """创建或幂等导入知识条目的请求体。""" + + title: str = Field(..., min_length=1, max_length=256, description="知识标题") + source_type: KnowledgeSourceType = Field(default="other", description="来源类型") + content: str = Field(..., min_length=1, description="文本或Markdown正文") + source_uri: str | None = Field(None, max_length=1024, description="原始来源地址") + external_id: str | None = Field(None, max_length=256, description="外部系统稳定ID") + tags: list[str] = Field(default_factory=list, max_length=32, description="标签") + occurred_at: datetime = Field(..., description="资料实际发生时间") + + @field_validator("tags") + @classmethod + def validate_tags(cls, value: list[str]) -> list[str]: + """清理标签并限制单个标签长度。""" + tags: list[str] = [] + for raw_tag in value: + tag = raw_tag.strip() + if not tag or tag in tags: + continue + if len(tag) > 64: + raise ValueError("单个标签不能超过64个字符") + tags.append(tag) + return tags + + +class KnowledgeEntryUpdate(BaseModel): + """更新知识条目的请求体。""" + + title: str | None = Field(None, min_length=1, max_length=256, description="知识标题") + source_type: KnowledgeSourceType | None = Field(None, description="来源类型") + content: str | None = Field(None, min_length=1, description="文本或Markdown正文") + source_uri: str | None = Field(None, max_length=1024, description="原始来源地址") + external_id: str | None = Field(None, max_length=256, description="外部系统稳定ID") + tags: list[str] | None = Field(None, max_length=32, description="标签") + occurred_at: datetime | None = Field(None, description="资料实际发生时间") + + @model_validator(mode="after") + def validate_occurred_at(self) -> "KnowledgeEntryUpdate": + """禁止把已有知识条目的发生时间显式清空。""" + if "occurred_at" in self.model_fields_set and self.occurred_at is None: + raise ValueError("资料实际发生时间不能为空") + return self + + @field_validator("tags") + @classmethod + def validate_tags(cls, value: list[str] | None) -> list[str] | None: + """清理标签并限制单个标签长度。""" + if value is None: + return None + return KnowledgeEntryCreate.validate_tags(value) + + +class KnowledgeEntryResponse(BaseModel): + """知识条目响应体。""" + + id: int + project_id: int + title: str + source_type: KnowledgeSourceType + content: str + source_uri: str | None = None + external_id: str | None = None + tags: list[str] = Field(default_factory=list) + occurred_at: datetime | None = None + content_hash: str + created_at: datetime + updated_at: datetime + + +class KnowledgeBatchImportRequest(BaseModel): + """批量导入知识条目的请求体。""" + + entries: list[KnowledgeEntryCreate] = Field(..., min_length=1, max_length=100) + + +class KnowledgeBatchImportResponse(BaseModel): + """批量导入知识条目的响应体。""" + + created_count: int = 0 + updated_count: int = 0 + entries: list[KnowledgeEntryResponse] = Field(default_factory=list) diff --git a/backend/app/schemas/weekly_report.py b/backend/app/schemas/weekly_report.py new file mode 100644 index 0000000..ff17050 --- /dev/null +++ b/backend/app/schemas/weekly_report.py @@ -0,0 +1,296 @@ +"""项目周报 Schema。""" + +from datetime import date, datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +WeeklyReportStatus = Literal["collecting", "fact_review", "draft", "approved", "archived"] +WeeklyReportReviewStatus = Literal[ + "verified", + "pending_confirmation", + "needs_judgment", + "blocked_from_report", +] +WeeklyReportBusinessStatus = Literal["正常", "关注", "风险", "阻塞", "已完成"] +WeeklyReportDateType = Literal["planned", "forecast", "committed", "actual"] +WeeklyReportOverallStatus = Literal["正常", "关注", "风险", "阻塞"] + + +class WeeklyReportCreate(BaseModel): + """创建周报请求体。""" + + week_start: date = Field(..., description="统计开始日期") + week_end: date = Field(..., description="统计结束日期") + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64, description="统计时区") + cutoff_at: datetime | None = Field(None, description="数据截止时间") + title: str | None = Field(None, min_length=1, max_length=256, description="周报标题") + + @model_validator(mode="after") + def validate_period(self) -> "WeeklyReportCreate": + """校验周报周期。""" + if self.week_end < self.week_start: + raise ValueError("统计结束日期不能早于开始日期") + if (self.week_end - self.week_start).days > 31: + raise ValueError("单个周报周期不能超过31天") + return self + + +class WeeklyReportEvidenceCreate(BaseModel): + """创建周报证据请求体。""" + + source_type: str = Field(default="manual", min_length=1, max_length=32, description="来源类型") + source_id: str | None = Field(None, max_length=128, description="来源记录ID") + source_uri: str | None = Field(None, max_length=512, description="来源链接") + source_title: str | None = Field(None, max_length=256, description="来源标题") + source_excerpt: str | None = Field(None, max_length=12000, description="来源摘录") + source_updated_at: datetime | None = Field(None, description="来源更新时间") + source_content_hash: str | None = Field(None, max_length=64, description="来源内容哈希") + + +class WeeklyReportFactCreate(BaseModel): + """人工补充周报事实请求体。""" + + category: str = Field(..., min_length=1, max_length=32, description="事实分类") + module: str | None = Field(None, max_length=128, description="所属模块") + statement: str = Field(..., min_length=1, max_length=12000, description="事实陈述") + business_status: WeeklyReportBusinessStatus | None = Field(None, description="业务状态") + audience_scope: str = Field(default="internal", max_length=32, description="受众范围") + owner: str | None = Field(None, max_length=64, description="负责人") + action_due_at: date | None = Field(None, description="下一步动作截止日期") + next_action: str | None = Field(None, max_length=12000, description="下一步动作") + impact_type: str | None = Field(None, max_length=32, description="影响类型") + impact_description: str | None = Field(None, max_length=12000, description="影响说明") + decision_required: bool = Field(False, description="是否需要决策") + decision_owner: str | None = Field(None, max_length=64, description="决策人") + decision_due_at: date | None = Field(None, description="决策截止日期") + date_type: WeeklyReportDateType | None = Field(None, description="日期语义") + date_value: date | None = Field(None, description="事实日期") + evidence: list[WeeklyReportEvidenceCreate] = Field(default_factory=list, max_length=10, description="事实证据") + + +class WeeklyReportExternalFactCreate(WeeklyReportFactCreate): + """外部客户端导入候选事实请求体。""" + + external_id: str = Field(..., min_length=1, max_length=180, description="外部事实稳定ID") + + +class WeeklyReportFactBatchCreate(BaseModel): + """批量导入候选事实请求体。""" + + facts: list[WeeklyReportExternalFactCreate] = Field(..., min_length=1, max_length=100, description="候选事实") + + +class WeeklyReportFactBatchResponse(BaseModel): + """批量导入候选事实响应体。""" + + created_count: int + updated_count: int + fact_ids: list[int] + + +class WeeklyReportFactUpdate(BaseModel): + """更新周报事实请求体。""" + + category: str | None = Field(None, min_length=1, max_length=32, description="事实分类") + module: str | None = Field(None, max_length=128, description="所属模块") + statement: str | None = Field(None, min_length=1, max_length=12000, description="事实陈述") + business_status: WeeklyReportBusinessStatus | None = Field(None, description="业务状态") + review_status: WeeklyReportReviewStatus | None = Field(None, description="审核状态") + audience_scope: str | None = Field(None, max_length=32, description="受众范围") + owner: str | None = Field(None, max_length=64, description="负责人") + action_due_at: date | None = Field(None, description="下一步动作截止日期") + next_action: str | None = Field(None, max_length=12000, description="下一步动作") + impact_type: str | None = Field(None, max_length=32, description="影响类型") + impact_description: str | None = Field(None, max_length=12000, description="影响说明") + decision_required: bool | None = Field(None, description="是否需要决策") + decision_owner: str | None = Field(None, max_length=64, description="决策人") + decision_due_at: date | None = Field(None, description="决策截止日期") + date_type: WeeklyReportDateType | None = Field(None, description="日期语义") + date_value: date | None = Field(None, description="事实日期") + + +class WeeklyReportGenerateRequest(BaseModel): + """生成周报草稿请求体。""" + + model: str | None = Field(None, max_length=128, description="临时模型") + api_key: str | None = Field(None, max_length=2048, description="临时API Key") + + +class WeeklyReportDraftUpdate(BaseModel): + """保存人工修订草稿请求体。""" + + content_markdown: str = Field(..., min_length=1, max_length=100000, description="Markdown草稿") + + +class WeeklyReportApproveRequest(BaseModel): + """批准周报请求体。""" + + source_version_id: int = Field(..., description="待批准草稿版本ID") + content_markdown: str = Field(..., min_length=1, max_length=100000, description="人工终稿") + overall_status: WeeklyReportOverallStatus = Field(..., description="人工确认的项目总体状态") + + +class WeeklyReportEvidenceResponse(BaseModel): + """周报证据响应体。""" + + id: int + source_type: str + source_id: str | None = None + source_uri: str | None = None + source_title: str | None = None + source_excerpt: str | None = None + source_updated_at: datetime | None = None + source_content_hash: str | None = None + created_at: datetime + + +class WeeklyReportFactResponse(BaseModel): + """周报事实响应体。""" + + id: int + report_id: int + source_key: str | None = None + category: str + module: str | None = None + statement: str + business_status: str | None = None + review_status: str + audience_scope: str + owner: str | None = None + action_due_at: date | None = None + next_action: str | None = None + impact_type: str | None = None + impact_description: str | None = None + decision_required: bool + decision_owner: str | None = None + decision_due_at: date | None = None + date_type: str | None = None + date_value: date | None = None + previous_value: dict[str, Any] | None = None + current_value: dict[str, Any] | None = None + source_mode: str + created_by: int | None = None + verified_by: int | None = None + verified_at: datetime | None = None + created_at: datetime + updated_at: datetime + evidence: list[WeeklyReportEvidenceResponse] = Field(default_factory=list) + + +class WeeklyReportVersionResponse(BaseModel): + """周报版本响应体。""" + + model_config = ConfigDict(protected_namespaces=()) + + id: int + report_id: int + audience_type: str + version_no: int + version_stage: str + content_markdown: str + fact_ids: list[int] + fact_hashes: dict[str, str] + prompt_version: str + model_name: str | None = None + generated_by: int | None = None + generated_at: datetime + approved_by: int | None = None + approved_at: datetime | None = None + created_at: datetime + + +class WeeklyReportCoverageResponse(BaseModel): + """周报输入覆盖响应体。""" + + plan_count: int = 0 + milestone_count: int = 0 + requirement_count: int = 0 + issue_count: int = 0 + knowledge_count: int = 0 + critical_plan_count: int = 0 + missing_assignee_count: int = 0 + missing_date_count: int = 0 + late_arrival_source_count: int = 0 + post_cutoff_source_count: int = 0 + critical_path_unavailable_count: int = 0 + truncated_source_count: int = 0 + + +class WeeklyReportSummaryResponse(BaseModel): + """周报摘要响应体。""" + + id: int + project_id: int + week_start: date + week_end: date + timezone: str + cutoff_at: datetime + title: str + status: WeeklyReportStatus + overall_status: str | None = None + fact_count: int + verified_count: int + pending_count: int + latest_version_id: int | None = None + created_by: int + approved_by: int | None = None + approved_at: datetime | None = None + archived_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class WeeklyReportDetailResponse(WeeklyReportSummaryResponse): + """周报详情响应体。""" + + model_config = ConfigDict(protected_namespaces=()) + + prompt_template_key: str + prompt_version: str + model_name: str | None = None + reviewed_by: int | None = None + coverage: WeeklyReportCoverageResponse + facts: list[WeeklyReportFactResponse] = Field(default_factory=list) + versions: list[WeeklyReportVersionResponse] = Field(default_factory=list) + + +class WeeklyReportCollectResponse(BaseModel): + """采集周报候选事实响应体。""" + + created_count: int + updated_count: int + coverage: WeeklyReportCoverageResponse + report: WeeklyReportDetailResponse + + +class WeeklyReportValidationResponse(BaseModel): + """周报校验响应体。""" + + can_approve: bool + blockers: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + +class GeneratedWeeklyReportItem(BaseModel): + """模型生成的周报条目。""" + + text: str = Field(..., min_length=1, max_length=4000) + fact_ids: list[int] = Field(..., min_length=1, max_length=20) + + +class GeneratedWeeklyReportSection(BaseModel): + """模型生成的周报章节。""" + + heading: str = Field(..., min_length=1, max_length=64) + items: list[GeneratedWeeklyReportItem] = Field(default_factory=list, max_length=50) + + +class GeneratedWeeklyReportPayload(BaseModel): + """模型生成的结构化周报。""" + + title: str = Field(..., min_length=1, max_length=256) + summary: str = Field(..., min_length=1, max_length=4000) + summary_fact_ids: list[int] = Field(..., min_length=1, max_length=20) + sections: list[GeneratedWeeklyReportSection] = Field(..., min_length=1, max_length=10) diff --git a/backend/app/schemas/wiki.py b/backend/app/schemas/wiki.py new file mode 100644 index 0000000..76b79a7 --- /dev/null +++ b/backend/app/schemas/wiki.py @@ -0,0 +1,159 @@ +"""项目 LLM Wiki 请求与响应结构。""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + + +WikiSourceStatus = Literal[ + "inbox", + "compiling", + "compiled", + "failed", + "unsupported", + "superseded", +] +WikiPageType = Literal["topic", "concept", "synthesis", "idea"] +WikiRelationType = Literal["source_claim", "ai_inference", "evidence"] + + +class WikiPageSourceReference(BaseModel): + """Wiki 页面引用的来源摘要。""" + + source_id: str = Field(..., description="稳定来源ID") + title: str = Field(..., description="来源标题") + relation_type: WikiRelationType = Field(..., description="引用关系类型") + trust_level: str = Field(..., description="来源可信等级") + + +class WikiSourcePageReference(BaseModel): + """来源参与编译的 Wiki 页面摘要。""" + + id: int = Field(..., description="Wiki页面ID") + slug: str = Field(..., description="页面稳定标识") + title: str = Field(..., description="页面标题") + page_type: WikiPageType = Field(..., description="页面类型") + relation_type: WikiRelationType = Field(..., description="引用关系类型") + + +class WikiSourceResponse(BaseModel): + """Wiki 来源快照响应体。""" + + id: int + project_id: int + author_user_id: int | None = None + source_id: str + origin_kind: str + origin_ref: str | None = None + title: str + source_type: str + source_uri: str | None = None + trust_level: str + status: WikiSourceStatus + compiled_markdown: str | None = None + error_message: str | None = None + occurred_at: datetime | None = None + imported_at: datetime + compiled_at: datetime | None = None + created_at: datetime + updated_at: datetime + content_hash: str + snapshot_hash: str + raw_content_preview: str + raw_content_truncated: bool = False + pages: list[WikiSourcePageReference] = Field(default_factory=list) + + +class WikiPageResponse(BaseModel): + """Wiki 长期知识页面响应体。""" + + id: int + project_id: int + slug: str + page_type: WikiPageType + title: str + summary: str + content_markdown: str + status: str + revision: int + created_at: datetime + updated_at: datetime + sources: list[WikiPageSourceReference] = Field(default_factory=list) + + +class WikiCompilationRunResponse(BaseModel): + """Wiki 编译批次响应体。""" + + id: int + project_id: int + requested_by_user_id: int | None = None + trigger: str + status: str + model: str | None = None + source_count: int + page_count: int + lint_errors: list[str] = Field(default_factory=list) + error_message: str | None = None + started_at: datetime + finished_at: datetime | None = None + + +class ProjectWikiOverviewResponse(BaseModel): + """项目 Wiki 总览响应体。""" + + project_id: int + source_counts: dict[str, int] = Field(default_factory=dict) + page_counts: dict[str, int] = Field(default_factory=dict) + sources: list[WikiSourceResponse] = Field(default_factory=list) + pages: list[WikiPageResponse] = Field(default_factory=list) + recent_runs: list[WikiCompilationRunResponse] = Field(default_factory=list) + + +class WikiCompileRequest(BaseModel): + """手动触发 Wiki 编译请求体。""" + + source_ids: list[str] | None = Field( + None, + min_length=1, + max_length=50, + description="可选的来源ID列表", + ) + include_failed: bool = Field(False, description="是否同时重试失败来源") + + @field_validator("source_ids") + @classmethod + def normalize_source_ids(cls, value: list[str] | None) -> list[str] | None: + """清理并去重来源ID。""" + if value is None: + return None + normalized: list[str] = [] + for raw_source_id in value: + source_id = raw_source_id.strip() + if not source_id or source_id in normalized: + continue + if len(source_id) > 64: + raise ValueError("来源ID长度不能超过64个字符") + normalized.append(source_id) + if not normalized: + raise ValueError("至少提供一个有效来源ID") + return normalized + + +class WikiCompiledPagePayload(BaseModel): + """模型返回的单个 Wiki 页面变更。""" + + slug: str = Field(..., min_length=1, max_length=160) + page_type: WikiPageType + title: str = Field(..., min_length=1, max_length=256) + summary: str = Field(..., min_length=1, max_length=1200) + content_markdown: str = Field(..., min_length=1, max_length=60000) + relation_type: WikiRelationType = "source_claim" + + +class WikiCompilationPayload(BaseModel): + """模型返回的来源增量编译结果。""" + + should_compile: bool + source_note_markdown: str = Field(..., min_length=1, max_length=30000) + pages: list[WikiCompiledPagePayload] = Field(default_factory=list, max_length=12) diff --git a/backend/app/services/agent.py b/backend/app/services/agent.py index 5d7d615..efcbd33 100644 --- a/backend/app/services/agent.py +++ b/backend/app/services/agent.py @@ -48,6 +48,8 @@ AGENT_RUNTIME_PROMPT = """ 你是 TinyPM 的项目管理智能助理。你只能通过项目数据 MCP 工具读取或修改项目数据。 当用户询问项目、计划、需求或问题时,先调用 MCP 工具获取真实数据,再基于工具结果回答。 +当用户询问风险、预警、延期或项目健康度时,必须先调用 get_project_risks。规则命中属于系统事实;额外语义风险必须明确标记为“AI 推断”。 +当问题涉及会议决定、历史讨论、技术背景或开发细节时,先调用 search_project_knowledge 检索项目知识库。 当用户要求审核“当前计划”且提供了 plan_id 时,只调用 get_project_plan 获取该单条计划,不要把 list_project_plans 的全量结果当作审核对象。 当用户要求新增或更新计划时,必须调用对应 MCP 工具;没有权限或参数不足时,清楚说明需要补充的信息。 回答使用简体中文,保持简洁、具体、可执行。 @@ -58,26 +60,296 @@ "deepseek-v4-pro": "deepseek-v4-pro", } +PROMPT_TEMPLATES: dict[str, dict[str, str]] = { + "ota_pm": { + "name": "车载 OTA 项目经理", + "description": "适合 OTA、远程诊断、车载电子和量产交付项目。", + "content": DEFAULT_SYSTEM_PROMPT, + }, + "delivery_pm": { + "name": "交付型项目经理", + "description": "适合普通软件交付、客户项目和跨团队协作。", + "content": """ +你是一位资深项目经理,擅长把项目目标拆解为计划、风险、问题和行动项。 +你需要基于 TinyPM MCP 工具读取项目真实数据,不要臆测项目状态。 +回答时优先给出结论、依据、风险和下一步动作;需要写入项目数据时,必须说明目标记录和变更理由。 +""".strip(), + }, + "aspice_reviewer": { + "name": "ASPICE 评审顾问", + "description": "适合过程审核、交付物完整性和项目流程风险识别。", + "content": """ +你是一位 ASPICE 和车载电子项目评审顾问,关注需求、设计、测试、问题闭环和交付证据。 +你需要基于 TinyPM MCP 工具读取项目计划、需求和问题数据,识别过程断点、责任缺失和证据缺失。 +回答必须给出可执行整改建议,并区分高、中、低风险。 +""".strip(), + }, +} + +OUTPUT_TEMPLATES: dict[str, dict[str, str]] = { + "concise_answer": { + "name": "简洁行动建议", + "description": "用于日常问答和快速查询。", + "content": """ +请按以下结构输出: +1. 结论 +2. 数据依据 +3. 建议动作 +如果信息不足,直接列出还缺哪些数据。 +""".strip(), + }, + "project_review_report": { + "name": "项目总审报告", + "description": "用于分项目的整体计划、需求、问题和风险审核。", + "content": """ +请按以下结构输出: +## 项目总审结论 +- 总体风险等级: +- 关键判断: + +## 数据覆盖范围 +- 已读取的 MCP 数据: +- 计划记录数量: +- 需求记录数量: +- 问题记录数量: + +## 主要风险 +| 风险 | 等级 | 依据 | 建议动作 | + +## 字段和流程缺口 +| 缺口 | 影响 | 建议补齐方式 | + +## 建议本周动作 +按优先级列出 3-5 条可执行动作。 +""".strip(), + }, + "single_plan_review": { + "name": "单条计划审核", + "description": "用于当前选中计划的字段完整性和延期风险审核。", + "content": """ +请只审核单条计划,并按以下结构输出: +## 计划审核结论 +- 计划ID: +- 风险等级: +- 是否需要 PM 介入: + +## 字段完整性 +| 字段 | 当前值 | 判断 | + +## 风险判断 +- 延期风险: +- 依赖风险: +- 责任风险: + +## 建议动作 +列出可直接执行的下一步。 +""".strip(), + }, + "schedule_change": { + "name": "排期调整建议", + "description": "用于计划提前、延期、工期变化和依赖影响分析。", + "content": """ +请按以下结构输出: +## 调整理解 +- 目标计划: +- 调整规则: + +## 影响分析 +- 开始时间: +- 结束时间: +- 工期: +- 依赖影响: + +## 建议变更 +如需要回写,请列出字段名、原值、新值和理由。 +""".strip(), + }, + "weekly_summary": { + "name": "项目周会摘要", + "description": "用于项目周会、客户同步和内部进度例会。", + "content": """ +请按以下结构输出: +## 本周重点 +## 计划进展 +## 阻塞和风险 +## 需要决策 +## 下周动作 +每条动作尽量包含负责人、目标日期和数据依据。 +""".strip(), + }, + "write_preview": { + "name": "数据写入预览", + "description": "用于 Agent 回写计划或问题前后的确认说明。", + "content": """ +请按以下结构输出: +## 操作结果 +- 是否已写入: +- MCP 工具: + +## 变更明细 +| 对象 | 字段 | 原值 | 新值 | 理由 | + +## 后续建议 +列出需要人工确认或继续跟踪的事项。 +""".strip(), + }, +} + +READ_PROJECT_TOOLS = [ + "list_projects", + "get_project_detail", + "list_project_milestones", + "list_project_plans", + "get_project_plan", + "get_project_critical_path", + "get_project_risks", + "list_project_requirements", + "list_project_issues", + "search_project_knowledge", +] + +WEEKLY_REPORT_READ_TOOLS = [ + "get_project_weekly_inputs", + "get_weekly_report_context", + "list_weekly_report_facts", + "get_weekly_report_evidence", +] + +PLAN_WRITE_TOOLS = [ + "create_project_plan", + "update_project_plan", +] + +ISSUE_WRITE_TOOLS = [ + "create_project_issue", + "update_project_issue", +] + AGENT_SKILLS: dict[str, dict[str, Any]] = { "general": { - "name": "通用项目助理", - "description": "适合日常项目问答、数据查询和常规更新。", - "system_prompt": "优先给出明确结论和下一步动作,避免空泛说明。", + "name": "项目数据助理", + "category": "通用", + "description": "日常项目问答、数据查询和轻量分析。", + "requires_project": True, + "allowed_tools": READ_PROJECT_TOOLS, + "write_tools": [], + "output_template_id": "concise_answer", + "prompt_template_id": "ota_pm", + "quick_prompts": [ + {"label": "当前项目概览", "prompt": "请读取当前项目数据,给出项目概览、计划数量、需求数量、问题数量和当前主要关注点。"}, + {"label": "待跟进事项", "prompt": "请基于当前项目计划和问题,整理今天 PM 最应该跟进的事项。"}, + {"label": "数据缺口", "prompt": "请检查当前项目有哪些关键数据缺失,会影响后续项目判断。"}, + ], + "system_prompt": "优先调用 MCP 获取项目真实数据,再给出明确结论和下一步动作。", + }, + "project_review": { + "name": "项目总审", + "category": "审核", + "description": "按项目整体审核计划、需求、问题和风险。", + "requires_project": True, + "allowed_tools": READ_PROJECT_TOOLS, + "write_tools": [], + "output_template_id": "project_review_report", + "prompt_template_id": "ota_pm", + "quick_prompts": [ + {"label": "发起项目总审", "prompt": "请对当前项目发起项目总审,覆盖整体计划风险、字段缺失、延期项、依赖风险和建议动作。"}, + {"label": "交付风险", "prompt": "请识别当前项目最可能影响交付的 5 个风险,并给出依据和动作。"}, + {"label": "流程缺口", "prompt": "请从 PM 视角检查当前项目流程和数据闭环缺口。"}, + ], + "system_prompt": "总审必须先调用 get_project_risks,再读取项目详情、计划、需求和问题。规则命中与 AI 推断必须分开呈现。不要只基于用户描述下结论。", + }, + "risk_watch": { + "name": "项目风险预警", + "category": "审核", + "description": "读取规则预警并结合项目知识识别潜在风险。", + "requires_project": True, + "allowed_tools": READ_PROJECT_TOOLS, + "write_tools": [], + "output_template_id": "project_review_report", + "prompt_template_id": "delivery_pm", + "quick_prompts": [ + {"label": "查看当前预警", "prompt": "请读取当前项目预警,按高、中、低说明规则命中、依据和立即动作。"}, + {"label": "识别潜在风险", "prompt": "请先读取项目预警,再结合项目知识库识别规则尚未覆盖的潜在风险;务必区分规则命中与 AI 推断。"}, + {"label": "风险处置排序", "prompt": "请按交付影响和紧迫性排序当前项目风险,并给出本周处置清单。"}, + ], + "system_prompt": "风险分析必须先调用 get_project_risks。规则命中是确定性事实;知识库分析得到的内容必须标记为 AI 推断并给出来源。", + }, + "plan_review": { + "name": "单条计划审核", + "category": "审核", + "description": "只审核当前选中的一条计划,避免误审全量计划。", + "requires_project": True, + "allowed_tools": ["get_project_detail", "get_project_plan"], + "write_tools": [], + "output_template_id": "single_plan_review", + "prompt_template_id": "ota_pm", + "quick_prompts": [ + {"label": "审核当前计划", "prompt": "请只审核当前选中的计划,指出字段缺失、延期风险、依赖风险和建议动作。"}, + {"label": "责任检查", "prompt": "请检查当前计划是否存在责任人、依赖或交付描述不清的问题。"}, + ], + "system_prompt": "如果用户提供 plan_id,必须使用 get_project_plan 获取单条计划;不要调用 list_project_plans 做全量扫描。", + }, + "schedule_planner": { + "name": "排期与依赖分析", + "category": "计划", + "description": "处理开始时间、结束时间、工期、依赖和延期影响。", + "requires_project": True, + "allowed_tools": READ_PROJECT_TOOLS, + "write_tools": [], + "output_template_id": "schedule_change", + "prompt_template_id": "ota_pm", + "quick_prompts": [ + {"label": "延期影响", "prompt": "请分析当前项目计划中的延期风险和可能受影响的后续任务。"}, + {"label": "排期冲突", "prompt": "请检查当前项目计划是否存在时间重叠、依赖不清或工期异常。"}, + {"label": "工期重算", "prompt": "请根据计划开始和结束时间,检查工期字段是否可能需要重算。"}, + ], + "system_prompt": "分析排期时必须明确开始日期、结束日期、工期和依赖关系;不要把工期拉长、结束日期不变等规则混淆。", }, - "planner": { - "name": "计划排程", - "description": "关注计划排期、依赖关系、工期调整和任务拆解。", - "system_prompt": "当用户描述时间调整或排期变更时,优先定位具体计划记录,并明确开始、结束、工期之间的关系。", + "weekly_summary": { + "name": "周会摘要", + "category": "汇报", + "description": "生成非正式周会摘要;正式周报应进入项目周报工作台审核。", + "requires_project": True, + "allowed_tools": READ_PROJECT_TOOLS + WEEKLY_REPORT_READ_TOOLS, + "write_tools": [], + "output_template_id": "weekly_summary", + "prompt_template_id": "delivery_pm", + "quick_prompts": [ + {"label": "生成周会摘要", "prompt": "请为当前项目生成下一次项目周会摘要,覆盖进展、风险、阻塞、决策和下周动作。"}, + {"label": "客户汇报", "prompt": "请把当前项目数据整理成适合客户同步的简洁汇报。"}, + ], + "system_prompt": "周会摘要必须把项目数据转成可追踪行动项,不要只复述计划列表;聊天结果仅为非正式草稿,正式周报必须基于已核验事实底稿。", }, - "reviewer": { - "name": "审核专员", - "description": "关注字段完整性、进度异常和交付前检查。", - "system_prompt": "审核时先验证关键字段完整性,再指出风险等级和建议动作。", + "risk_to_issue": { + "name": "风险转问题", + "category": "自动化", + "description": "把明确风险沉淀为问题记录,减少人工录入。", + "requires_project": True, + "allowed_tools": READ_PROJECT_TOOLS + ISSUE_WRITE_TOOLS, + "write_tools": ISSUE_WRITE_TOOLS, + "output_template_id": "write_preview", + "prompt_template_id": "ota_pm", + "quick_prompts": [ + {"label": "生成问题草案", "prompt": "请基于当前项目风险生成问题清单草案,说明哪些建议写入项目问题。"}, + {"label": "写入高风险问题", "prompt": "请把当前项目中明确的高风险事项写入问题清单,并说明每条问题的数据依据。"}, + ], + "system_prompt": "先调用 get_project_risks,再检查已有问题,避免重复创建。只有用户明确要求写入时,才调用 create_project_issue 或 update_project_issue。", }, - "editor": { - "name": "数据维护", - "description": "适合通过 MCP 回写项目数据,减少人工录入。", - "system_prompt": "当用户要修改项目计划或项目字段时,先确认目标记录,再直接调用 MCP 工具完成更新。", + "data_operator": { + "name": "项目数据维护", + "category": "自动化", + "description": "通过 MCP 新增或更新计划,减少重复录入。", + "requires_project": True, + "allowed_tools": READ_PROJECT_TOOLS + PLAN_WRITE_TOOLS + ISSUE_WRITE_TOOLS, + "write_tools": PLAN_WRITE_TOOLS + ISSUE_WRITE_TOOLS, + "output_template_id": "write_preview", + "prompt_template_id": "delivery_pm", + "quick_prompts": [ + {"label": "更新计划", "prompt": "请根据我的描述定位目标计划,并通过 MCP 更新项目计划数据。"}, + {"label": "新增计划", "prompt": "请根据我的描述创建新的项目计划记录,缺少字段先向我确认。"}, + {"label": "创建问题", "prompt": "请根据我的描述创建项目问题记录,缺少字段先向我确认。"}, + ], + "system_prompt": "执行写操作前必须定位目标记录,说明即将修改的字段;参数足够且用户明确要求执行时再调用写入 MCP 工具。", }, } @@ -105,12 +377,48 @@ def list_skills() -> list[dict[str, Any]]: { "id": skill_id, "name": item["name"], + "category": item["category"], "description": item["description"], + "requires_project": item["requires_project"], + "can_write": bool(item["write_tools"]), + "tool_names": item["allowed_tools"], + "write_tool_names": item["write_tools"], + "prompt_template_id": item["prompt_template_id"], + "prompt_template_name": PROMPT_TEMPLATES[item["prompt_template_id"]]["name"], + "output_template_id": item["output_template_id"], + "output_template_name": OUTPUT_TEMPLATES[item["output_template_id"]]["name"], + "quick_prompts": item["quick_prompts"], } for skill_id, item in AGENT_SKILLS.items() ] +def list_prompt_templates() -> list[dict[str, str]]: + """返回系统 Prompt 模板目录。""" + return [ + { + "id": template_id, + "name": item["name"], + "description": item["description"], + "content": item["content"], + } + for template_id, item in PROMPT_TEMPLATES.items() + ] + + +def list_output_templates() -> list[dict[str, str]]: + """返回输出模板目录。""" + return [ + { + "id": template_id, + "name": item["name"], + "description": item["description"], + "content": item["content"], + } + for template_id, item in OUTPUT_TEMPLATES.items() + ] + + def get_or_create_setting(db: Session, user: User) -> AgentSetting: """获取或创建用户智能助理配置。""" setting = db.query(AgentSetting).filter(AgentSetting.user_id == user.id).first() @@ -240,6 +548,8 @@ async def chat_with_agent( tool_summaries: list[AgentToolCallSummary] = [] messages = _build_conversation_messages(session, request.message) + allowed_tool_names = _get_allowed_tool_names(session) + openai_tools = project_mcp.list_openai_tools(allowed_tool_names) try: async with httpx.AsyncClient(timeout=60) as client: @@ -250,6 +560,7 @@ async def chat_with_agent( api_key=api_key, model=model, messages=messages, + tools=openai_tools, ) tool_calls = response_message.get("tool_calls") or [] messages.append(response_message) @@ -259,6 +570,7 @@ async def chat_with_agent( _persist_round( db=db, session=session, + user=user, user_message=request.message, assistant_message=answer, model=model, @@ -268,7 +580,12 @@ async def chat_with_agent( return session.id, answer, model, tool_summaries for tool_call in tool_calls: - tool_result, summary = _execute_tool_call(db, user, tool_call) + tool_result, summary = _execute_tool_call( + db, + user, + tool_call, + allowed_tool_names, + ) tool_summaries.append(summary) messages.append( { @@ -281,6 +598,7 @@ async def chat_with_agent( _persist_round( db=db, session=session, + user=user, user_message=request.message, assistant_message=str(exc.detail), model=model, @@ -293,6 +611,7 @@ async def chat_with_agent( _persist_round( db=db, session=session, + user=user, user_message=request.message, assistant_message=error_detail, model=model, @@ -302,6 +621,38 @@ async def chat_with_agent( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=error_detail) +async def request_plain_completion( + *, + api_base_url: str, + api_key: str, + model: str, + messages: list[dict[str, Any]], +) -> tuple[str, str]: + """复用现有重试策略执行不带工具的模型请求。""" + normalized_model = _normalize_model_name(model) + if not api_key.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请先填写 DeepSeek API Key", + ) + async with httpx.AsyncClient(timeout=60) as client: + response_message = await _request_chat_completion( + client, + api_base_url=api_base_url.rstrip("/"), + api_key=api_key, + model=normalized_model, + messages=messages, + tools=[], + ) + content = response_message.get("content") + if not isinstance(content, str) or not content.strip(): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="大模型响应缺少可用内容", + ) + return content.strip(), normalized_model + + def _resolve_chat_session(db: Session, user: User, request: AgentChatRequest) -> AgentSession: """解析对话对应的会话。""" if request.session_id is not None: @@ -351,19 +702,65 @@ def _build_conversation_messages(session: AgentSession, current_message: str) -> def _build_system_prompt(session: AgentSession) -> str: """构造系统提示词。""" - skill = AGENT_SKILLS[_normalize_skill_id(session.skill_id)] - lines = [session.system_prompt or DEFAULT_SYSTEM_PROMPT, AGENT_RUNTIME_PROMPT, skill["system_prompt"]] + skill_id = _normalize_skill_id(session.skill_id) + skill = AGENT_SKILLS[skill_id] + output_template = OUTPUT_TEMPLATES[skill["output_template_id"]] + lines = [ + session.system_prompt or DEFAULT_SYSTEM_PROMPT, + AGENT_RUNTIME_PROMPT, + _format_skill_runtime_prompt(skill_id, skill), + _format_output_template_prompt(output_template), + ] if session.project_id is not None: lines.append(f"当前会话绑定项目ID:{session.project_id}。如果用户没有明确切换项目,就按这个项目处理。") return "\n".join(lines) +def _get_allowed_tool_names(session: AgentSession) -> list[str]: + """获取当前 Skill 允许的 MCP 工具。""" + skill = AGENT_SKILLS[_normalize_skill_id(session.skill_id)] + return list(dict.fromkeys(skill["allowed_tools"])) + + +def _format_skill_runtime_prompt(skill_id: str, skill: dict[str, Any]) -> str: + """生成当前 Skill 的运行提示。""" + tool_names = "、".join(skill["allowed_tools"]) or "无" + write_tool_names = "、".join(skill["write_tools"]) or "无" + write_rule = ( + "当前 Skill 允许写入项目数据。写入前必须定位目标对象,说明字段和理由;只有用户明确要求执行时才调用写入工具。" + if skill["write_tools"] + else "当前 Skill 不允许写入项目数据。如用户要求修改数据,请先给出建议,并提示切换到数据维护或风险转问题 Skill。" + ) + return "\n".join( + [ + f"当前 Skill:{skill['name']}({skill_id})。", + f"Skill 目标:{skill['description']}", + f"可用 MCP 工具:{tool_names}", + f"写入类 MCP 工具:{write_tool_names}", + write_rule, + f"执行要求:{skill['system_prompt']}", + ] + ) + + +def _format_output_template_prompt(template: dict[str, str]) -> str: + """生成输出模板提示。""" + return "\n".join( + [ + f"本次回答默认使用输出模板:{template['name']}。", + "除非用户明确要求其他格式,否则按以下模板组织回答:", + template["content"], + ] + ) + + async def _request_chat_completion( client: httpx.AsyncClient, api_base_url: str, api_key: str, model: str, messages: list[dict[str, Any]], + tools: list[dict[str, Any]], ) -> dict[str, Any]: """带重试请求 OpenAI 兼容 Chat Completions 接口。""" failures: list[str] = [] @@ -375,6 +772,7 @@ async def _request_chat_completion( api_key=api_key, model=model, messages=messages, + tools=tools, ) except HTTPException as exc: reason = str(exc.detail) @@ -405,15 +803,17 @@ async def _request_chat_completion_once( api_key: str, model: str, messages: list[dict[str, Any]], + tools: list[dict[str, Any]], ) -> dict[str, Any]: """执行单次 OpenAI 兼容 Chat Completions 请求。""" - payload = { + payload: dict[str, Any] = { "model": model, "messages": messages, - "tools": project_mcp.list_openai_tools(), - "tool_choice": "auto", "temperature": 0.2, } + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" try: response = await client.post( f"{api_base_url}/chat/completions", @@ -458,6 +858,7 @@ def _execute_tool_call( db: Session, user: User, tool_call: dict[str, Any], + allowed_tool_names: list[str], ) -> tuple[dict[str, Any], AgentToolCallSummary]: """执行模型发起的 MCP 工具调用。""" function_data = tool_call.get("function") or {} @@ -468,6 +869,16 @@ def _execute_tool_call( except json.JSONDecodeError: arguments = {} + if name not in allowed_tool_names: + result = {"error": f"当前 Skill 不允许调用 MCP 工具 {name}"} + summary = AgentToolCallSummary( + name=name, + arguments=arguments, + success=False, + result_preview=result["error"], + ) + return result, summary + try: result = project_mcp.call_tool(db, user, name, arguments) summary = AgentToolCallSummary( @@ -491,6 +902,7 @@ def _execute_tool_call( def _persist_round( db: Session, session: AgentSession, + user: User, user_message: str, assistant_message: str, model: str, @@ -526,6 +938,18 @@ def _persist_round( session.title = _build_session_title(user_message) session.last_message_at = datetime.now(timezone.utc) session.updated_at = datetime.now(timezone.utc) + db.flush() + if not is_error and session.project_id is not None: + from app.services import wiki as wiki_service + + wiki_service.ingest_chat_round( + db, + session, + user_record, + assistant_record, + author=user, + tool_summaries=tool_summaries, + ) db.commit() diff --git a/backend/app/services/project.py b/backend/app/services/project.py index f04b37d..a43ebb2 100644 --- a/backend/app/services/project.py +++ b/backend/app/services/project.py @@ -1,9 +1,12 @@ """项目管理服务模块。""" -from datetime import date, datetime +from datetime import date, datetime, timezone from decimal import Decimal +import hashlib from io import BytesIO +import json from pathlib import Path +import re from uuid import uuid4 from fastapi import HTTPException, UploadFile, status @@ -22,8 +25,10 @@ ProjectDocumentFile, ProjectIssue, ProjectIssueImage, + ProjectKnowledgeEntry, ProjectMilestone, ProjectPlan, + ProjectPlanDependency, ProjectRequirement, ) @@ -288,10 +293,14 @@ def create_plan( db: Session, project_id: int, phase_name: str, + task_code: str | None = None, primary_task: str | None = None, secondary_task: str | None = None, dependency: str | None = None, duration: str | None = None, + duration_days: int | None = None, + is_summary: bool = False, + predecessor_plan_ids: list[int] | None = None, progress_pct: int = 0, description: str | None = None, planned_start: date | None = None, @@ -305,11 +314,14 @@ def create_plan( get_project(db, project_id) plan = ProjectPlan( project_id=project_id, + task_code=_clean_optional_text(task_code), phase_name=phase_name.strip(), primary_task=_clean_optional_text(primary_task), secondary_task=_clean_optional_text(secondary_task), dependency=_clean_optional_text(dependency), duration=_clean_optional_text(duration), + duration_days=_coerce_duration_days(duration_days, duration), + is_summary=bool(is_summary), progress_pct=_coerce_progress_pct(progress_pct), description=description, planned_start=planned_start, @@ -320,6 +332,8 @@ def create_plan( assignee=assignee, ) db.add(plan) + db.flush() + _sync_plan_predecessors(db, plan, predecessor_plan_ids or []) db.commit() db.refresh(plan) return plan @@ -334,6 +348,8 @@ def update_plan(db: Session, plan_id: int, update_data: dict) -> ProjectPlan: detail=f"计划ID {plan_id} 不存在", ) + if "task_code" in update_data: + plan.task_code = _clean_optional_text(update_data["task_code"]) if "phase_name" in update_data: plan.phase_name = update_data["phase_name"].strip() if "primary_task" in update_data: @@ -344,6 +360,14 @@ def update_plan(db: Session, plan_id: int, update_data: dict) -> ProjectPlan: plan.dependency = _clean_optional_text(update_data["dependency"]) if "duration" in update_data: plan.duration = _clean_optional_text(update_data["duration"]) + if "duration_days" not in update_data: + plan.duration_days = _coerce_duration_days(None, plan.duration) + if "duration_days" in update_data: + plan.duration_days = _coerce_duration_days(update_data["duration_days"], plan.duration) + if "is_summary" in update_data: + plan.is_summary = bool(update_data["is_summary"]) + if "predecessor_plan_ids" in update_data: + _sync_plan_predecessors(db, plan, update_data["predecessor_plan_ids"] or []) if "progress_pct" in update_data: plan.progress_pct = _coerce_progress_pct(update_data["progress_pct"]) if "description" in update_data: @@ -378,6 +402,221 @@ def delete_plan(db: Session, plan_id: int) -> None: db.commit() +def create_plan_dependency( + db: Session, + project_id: int, + successor_plan_id: int, + predecessor_plan_id: int, + dependency_type: str = "FS", + lag_days: int = 0, +) -> ProjectPlanDependency: + """创建项目计划依赖关系。""" + get_project(db, project_id) + successor = _get_project_plan(db, project_id, successor_plan_id) + predecessor = _get_project_plan(db, project_id, predecessor_plan_id) + if successor.id == predecessor.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="计划不能依赖自身", + ) + + existing = ( + db.query(ProjectPlanDependency) + .filter( + ProjectPlanDependency.predecessor_plan_id == predecessor.id, + ProjectPlanDependency.successor_plan_id == successor.id, + ) + .first() + ) + if existing is not None: + return existing + + dependency = ProjectPlanDependency( + project_id=project_id, + predecessor_plan_id=predecessor.id, + successor_plan_id=successor.id, + dependency_type=_normalize_dependency_type(dependency_type), + lag_days=int(lag_days or 0), + ) + db.add(dependency) + changed_at = datetime.now(timezone.utc) + predecessor.updated_at = changed_at + successor.updated_at = changed_at + db.flush() + _assert_project_plan_graph_acyclic(db, project_id) + db.commit() + db.refresh(dependency) + return dependency + + +def delete_plan_dependency(db: Session, dependency_id: int) -> None: + """删除项目计划依赖关系。""" + dependency = ( + db.query(ProjectPlanDependency) + .filter(ProjectPlanDependency.id == dependency_id) + .first() + ) + if dependency is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"计划依赖ID {dependency_id} 不存在", + ) + changed_at = datetime.now(timezone.utc) + if dependency.predecessor is not None: + dependency.predecessor.updated_at = changed_at + if dependency.successor is not None: + dependency.successor.updated_at = changed_at + db.delete(dependency) + db.commit() + + +def calculate_critical_path(db: Session, project_id: int) -> dict: + """计算项目计划关键路径。""" + get_project(db, project_id) + plans = list_plans(db, project_id) + if not plans: + return { + "project_id": project_id, + "status": "empty", + "project_duration_days": 0, + "critical_plan_ids": [], + "critical_edges": [], + "warnings": [], + "items": [], + } + + plan_ids = [plan.id for plan in plans] + plan_map = {plan.id: plan for plan in plans} + dependencies = ( + db.query(ProjectPlanDependency) + .filter(ProjectPlanDependency.project_id == project_id) + .all() + ) + predecessor_ids_by_plan = {plan_id: set() for plan_id in plan_ids} + successor_ids_by_plan = {plan_id: set() for plan_id in plan_ids} + lag_by_edge: dict[tuple[int, int], int] = {} + warnings: list[str] = [] + + for dependency in dependencies: + if ( + dependency.predecessor_plan_id not in plan_map + or dependency.successor_plan_id not in plan_map + ): + warnings.append(f"依赖 {dependency.id} 指向不存在的计划,已忽略") + continue + predecessor_ids_by_plan[dependency.successor_plan_id].add(dependency.predecessor_plan_id) + successor_ids_by_plan[dependency.predecessor_plan_id].add(dependency.successor_plan_id) + lag_by_edge[(dependency.predecessor_plan_id, dependency.successor_plan_id)] = dependency.lag_days or 0 + + durations: dict[int, int] = {} + for plan in plans: + duration_days = _resolve_plan_duration_days(plan) + if duration_days is None: + warnings.append(f"计划 {plan.task_code or plan.id} 缺少可计算工期") + else: + durations[plan.id] = duration_days + + base_items = [ + _build_critical_path_item( + plan=plan, + duration_days=durations.get(plan.id), + predecessor_ids=sorted(predecessor_ids_by_plan[plan.id]), + successor_ids=sorted(successor_ids_by_plan[plan.id]), + ) + for plan in plans + ] + if len(durations) != len(plans): + return { + "project_id": project_id, + "status": "invalid", + "project_duration_days": 0, + "critical_plan_ids": [], + "critical_edges": [], + "warnings": warnings, + "items": base_items, + } + + try: + order = _topological_order(plan_ids, predecessor_ids_by_plan) + except ValueError as exc: + warnings.append(str(exc)) + return { + "project_id": project_id, + "status": "invalid", + "project_duration_days": 0, + "critical_plan_ids": [], + "critical_edges": [], + "warnings": warnings, + "items": base_items, + } + + early_start: dict[int, int] = {} + early_finish: dict[int, int] = {} + for plan_id in order: + predecessor_finishes = [ + early_finish[predecessor_id] + lag_by_edge.get((predecessor_id, plan_id), 0) + for predecessor_id in predecessor_ids_by_plan[plan_id] + ] + early_start[plan_id] = max(predecessor_finishes, default=0) + early_finish[plan_id] = early_start[plan_id] + durations[plan_id] + + project_duration_days = max(early_finish.values(), default=0) + late_finish: dict[int, int] = {} + late_start: dict[int, int] = {} + for plan_id in reversed(order): + successor_starts = [ + late_start[successor_id] - lag_by_edge.get((plan_id, successor_id), 0) + for successor_id in successor_ids_by_plan[plan_id] + ] + late_finish[plan_id] = min(successor_starts, default=project_duration_days) + late_start[plan_id] = late_finish[plan_id] - durations[plan_id] + + items = [] + critical_plan_ids: list[int] = [] + for plan in plans: + total_float = late_start[plan.id] - early_start[plan.id] + is_critical = total_float == 0 + if is_critical: + critical_plan_ids.append(plan.id) + items.append( + _build_critical_path_item( + plan=plan, + duration_days=durations[plan.id], + predecessor_ids=sorted(predecessor_ids_by_plan[plan.id]), + successor_ids=sorted(successor_ids_by_plan[plan.id]), + early_start=early_start[plan.id], + early_finish=early_finish[plan.id], + late_start=late_start[plan.id], + late_finish=late_finish[plan.id], + total_float=total_float, + is_critical=is_critical, + ) + ) + + critical_plan_id_set = set(critical_plan_ids) + critical_edges = [ + { + "predecessor_plan_id": dependency.predecessor_plan_id, + "successor_plan_id": dependency.successor_plan_id, + } + for dependency in dependencies + if dependency.predecessor_plan_id in critical_plan_id_set + and dependency.successor_plan_id in critical_plan_id_set + and early_finish[dependency.predecessor_plan_id] + (dependency.lag_days or 0) + == early_start[dependency.successor_plan_id] + ] + + return { + "project_id": project_id, + "status": "ready", + "project_duration_days": project_duration_days, + "critical_plan_ids": critical_plan_ids, + "critical_edges": critical_edges, + "warnings": warnings, + "items": items, + } + + def import_plans_from_excel( db: Session, project_id: int, @@ -445,6 +684,8 @@ def import_plans_from_excel( secondary_task=raw_secondary_task, dependency=row_data.get("dependency"), duration=row_data.get("duration"), + duration_days=_coerce_duration_days(None, row_data.get("duration")), + is_summary=False, progress_pct=_parse_progress_value(row_data.get("progress_pct")), description=row_data.get("description"), planned_start=_parse_excel_date_value(row_data.get("planned_start")), @@ -677,6 +918,172 @@ def _clean_optional_text(value: object) -> str | None: return text or None +def _coerce_duration_days(value: object, fallback_text: object = None) -> int | None: + """转换可计算工期天数。""" + if value not in (None, ""): + duration_days = int(value) + return max(duration_days, 0) + if fallback_text in (None, ""): + return None + match = re.search(r"\d+", str(fallback_text)) + if match is None: + return None + return int(match.group()) + + +def _resolve_plan_duration_days(plan: ProjectPlan) -> int | None: + """解析计划工期天数。""" + return _coerce_duration_days(plan.duration_days, plan.duration) + + +def _normalize_dependency_type(value: str | None) -> str: + """标准化依赖类型。""" + dependency_type = (value or "FS").strip().upper() + if dependency_type != "FS": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前版本仅支持 FS 依赖类型", + ) + return dependency_type + + +def _get_project_plan(db: Session, project_id: int, plan_id: int) -> ProjectPlan: + """获取指定项目下的计划。""" + plan = ( + db.query(ProjectPlan) + .filter(ProjectPlan.id == plan_id, ProjectPlan.project_id == project_id) + .first() + ) + if plan is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目 {project_id} 下不存在计划 {plan_id}", + ) + return plan + + +def _sync_plan_predecessors( + db: Session, + plan: ProjectPlan, + predecessor_plan_ids: list[int], +) -> None: + """同步计划的前置依赖。""" + normalized_ids = sorted({int(item) for item in predecessor_plan_ids}) + if plan.id in normalized_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="计划不能依赖自身", + ) + + for predecessor_id in normalized_ids: + _get_project_plan(db, plan.project_id, predecessor_id) + + existing_links = ( + db.query(ProjectPlanDependency) + .filter(ProjectPlanDependency.successor_plan_id == plan.id) + .all() + ) + existing_ids = {link.predecessor_plan_id for link in existing_links} + target_ids = set(normalized_ids) + + if existing_ids != target_ids: + plan.updated_at = datetime.now(timezone.utc) + + for link in existing_links: + if link.predecessor_plan_id not in target_ids: + db.delete(link) + + for predecessor_id in target_ids - existing_ids: + db.add( + ProjectPlanDependency( + project_id=plan.project_id, + predecessor_plan_id=predecessor_id, + successor_plan_id=plan.id, + dependency_type="FS", + lag_days=0, + ) + ) + + db.flush() + _assert_project_plan_graph_acyclic(db, plan.project_id) + + +def _assert_project_plan_graph_acyclic(db: Session, project_id: int) -> None: + """校验项目计划依赖图不存在循环。""" + plans = db.query(ProjectPlan).filter(ProjectPlan.project_id == project_id).all() + dependencies = ( + db.query(ProjectPlanDependency) + .filter(ProjectPlanDependency.project_id == project_id) + .all() + ) + plan_ids = [plan.id for plan in plans] + predecessor_ids_by_plan = {plan_id: set() for plan_id in plan_ids} + for dependency in dependencies: + predecessor_ids_by_plan.setdefault(dependency.successor_plan_id, set()).add( + dependency.predecessor_plan_id + ) + predecessor_ids_by_plan.setdefault(dependency.predecessor_plan_id, set()) + _topological_order(plan_ids, predecessor_ids_by_plan) + + +def _topological_order( + plan_ids: list[int], + predecessor_ids_by_plan: dict[int, set[int]], +) -> list[int]: + """对计划依赖图进行拓扑排序。""" + remaining_predecessors = { + plan_id: set(predecessor_ids_by_plan.get(plan_id, set())) + for plan_id in plan_ids + } + successor_ids_by_plan = {plan_id: set() for plan_id in plan_ids} + for plan_id, predecessor_ids in remaining_predecessors.items(): + for predecessor_id in predecessor_ids: + successor_ids_by_plan.setdefault(predecessor_id, set()).add(plan_id) + + ready = sorted([plan_id for plan_id, predecessor_ids in remaining_predecessors.items() if not predecessor_ids]) + order: list[int] = [] + while ready: + plan_id = ready.pop(0) + order.append(plan_id) + for successor_id in sorted(successor_ids_by_plan.get(plan_id, set())): + remaining_predecessors[successor_id].discard(plan_id) + if not remaining_predecessors[successor_id] and successor_id not in order and successor_id not in ready: + ready.append(successor_id) + + if len(order) != len(plan_ids): + raise ValueError("计划依赖存在循环,无法计算关键路径") + return order + + +def _build_critical_path_item( + plan: ProjectPlan, + duration_days: int | None, + predecessor_ids: list[int], + successor_ids: list[int], + early_start: int | None = None, + early_finish: int | None = None, + late_start: int | None = None, + late_finish: int | None = None, + total_float: int | None = None, + is_critical: bool = False, +) -> dict: + """构建关键路径任务结果。""" + return { + "plan_id": plan.id, + "task_code": plan.task_code or f"P{plan.id}", + "name": plan.secondary_task or plan.primary_task or plan.phase_name, + "duration_days": duration_days, + "early_start": early_start, + "early_finish": early_finish, + "late_start": late_start, + "late_finish": late_finish, + "total_float": total_float, + "is_critical": is_critical, + "predecessor_plan_ids": predecessor_ids, + "successor_plan_ids": successor_ids, + } + + def _build_plan_template_header(worksheet: Worksheet, project_name: str) -> None: """生成项目开发计划模板表头。""" worksheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=36) @@ -1104,6 +1511,213 @@ def summarize_cost_records(records: list[ProjectCostRecord]) -> dict[str, float] } +def list_knowledge_entries( + db: Session, + project_id: int, + query: str | None = None, + source_type: str | None = None, + occurred_from: datetime | None = None, + occurred_to: datetime | None = None, + limit: int = 50, +) -> list[ProjectKnowledgeEntry]: + """按项目、关键词、来源类型和发生时间检索知识条目。""" + get_project(db, project_id) + knowledge_query = db.query(ProjectKnowledgeEntry).filter( + ProjectKnowledgeEntry.project_id == project_id + ) + + if source_type: + knowledge_query = knowledge_query.filter( + ProjectKnowledgeEntry.source_type == source_type + ) + if occurred_from is not None: + knowledge_query = knowledge_query.filter( + ProjectKnowledgeEntry.occurred_at >= occurred_from + ) + if occurred_to is not None: + knowledge_query = knowledge_query.filter( + ProjectKnowledgeEntry.occurred_at <= occurred_to + ) + if query and query.strip(): + fuzzy_keyword = f"%{query.strip()}%" + knowledge_query = knowledge_query.filter( + or_( + ProjectKnowledgeEntry.title.ilike(fuzzy_keyword), + ProjectKnowledgeEntry.content.ilike(fuzzy_keyword), + ProjectKnowledgeEntry.tags_json.ilike(fuzzy_keyword), + ) + ) + + safe_limit = max(1, min(limit, 100)) + return ( + knowledge_query.order_by( + ProjectKnowledgeEntry.occurred_at.desc().nullslast(), + ProjectKnowledgeEntry.created_at.desc(), + ProjectKnowledgeEntry.id.desc(), + ) + .limit(safe_limit) + .all() + ) + + +def get_knowledge_entry(db: Session, entry_id: int) -> ProjectKnowledgeEntry: + """根据ID获取项目知识条目。""" + entry = ( + db.query(ProjectKnowledgeEntry) + .filter(ProjectKnowledgeEntry.id == entry_id) + .first() + ) + if entry is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"知识条目ID {entry_id} 不存在", + ) + return entry + + +def upsert_knowledge_entry( + db: Session, + project_id: int, + *, + title: str, + source_type: str, + content: str, + source_uri: str | None = None, + external_id: str | None = None, + tags: list[str] | None = None, + occurred_at: datetime | None = None, + author_user_id: int | None = None, +) -> tuple[ProjectKnowledgeEntry, bool]: + """创建知识条目,存在相同外部ID时更新原记录。""" + get_project(db, project_id) + normalized_external_id = _clean_optional_text(external_id) + entry: ProjectKnowledgeEntry | None = None + if normalized_external_id: + entry = ( + db.query(ProjectKnowledgeEntry) + .filter( + ProjectKnowledgeEntry.project_id == project_id, + ProjectKnowledgeEntry.external_id == normalized_external_id, + ) + .first() + ) + + normalized_content = content.strip() + values = { + "title": title.strip(), + "source_type": source_type, + "content": normalized_content, + "source_uri": _clean_optional_text(source_uri), + "external_id": normalized_external_id, + "tags_json": _serialize_knowledge_tags(tags or []), + "occurred_at": occurred_at, + "content_hash": _hash_knowledge_content(normalized_content), + } + created = entry is None + if entry is None: + entry = ProjectKnowledgeEntry(project_id=project_id, **values) + db.add(entry) + else: + for field, value in values.items(): + setattr(entry, field, value) + + db.flush() + from app.services import wiki as wiki_service + + wiki_service.ingest_knowledge_entry( + db, + entry, + author_user_id=author_user_id, + ) + db.commit() + db.refresh(entry) + return entry, created + + +def update_knowledge_entry( + db: Session, + entry_id: int, + update_data: dict, + *, + author_user_id: int | None = None, +) -> ProjectKnowledgeEntry: + """更新知识条目。""" + entry = get_knowledge_entry(db, entry_id) + if "external_id" in update_data: + normalized_external_id = _clean_optional_text(update_data["external_id"]) + if normalized_external_id: + duplicate = ( + db.query(ProjectKnowledgeEntry) + .filter( + ProjectKnowledgeEntry.project_id == entry.project_id, + ProjectKnowledgeEntry.external_id == normalized_external_id, + ProjectKnowledgeEntry.id != entry.id, + ) + .first() + ) + if duplicate is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"external_id {normalized_external_id} 已存在", + ) + entry.external_id = normalized_external_id + if "title" in update_data: + entry.title = update_data["title"].strip() + if "source_type" in update_data: + entry.source_type = update_data["source_type"] + if "content" in update_data: + entry.content = update_data["content"].strip() + entry.content_hash = _hash_knowledge_content(entry.content) + if "source_uri" in update_data: + entry.source_uri = _clean_optional_text(update_data["source_uri"]) + if "tags" in update_data: + entry.tags_json = _serialize_knowledge_tags(update_data["tags"] or []) + if "occurred_at" in update_data: + entry.occurred_at = update_data["occurred_at"] + + db.flush() + from app.services import wiki as wiki_service + + wiki_service.ingest_knowledge_entry( + db, + entry, + author_user_id=author_user_id, + ) + db.commit() + db.refresh(entry) + return entry + + +def delete_knowledge_entry(db: Session, entry_id: int) -> None: + """删除知识条目。""" + entry = get_knowledge_entry(db, entry_id) + db.delete(entry) + db.commit() + + +def deserialize_knowledge_tags(tags_json: str | None) -> list[str]: + """将知识标签JSON转换为字符串列表。""" + if not tags_json: + return [] + try: + value = json.loads(tags_json) + except (TypeError, json.JSONDecodeError): + return [] + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def _serialize_knowledge_tags(tags: list[str]) -> str: + """将知识标签序列化为JSON。""" + return json.dumps(tags, ensure_ascii=False) + + +def _hash_knowledge_content(content: str) -> str: + """计算知识正文的SHA256摘要。""" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + def get_project_documents_state(db: Session, project_id: int) -> dict: """获取项目文档状态。""" project = get_project(db, project_id) @@ -1138,6 +1752,8 @@ def upload_project_documents( project_id: int, files: list[UploadFile], directory: str | None = None, + *, + author_user_id: int | None = None, ) -> list[ProjectDocumentFile]: """上传项目文档。""" get_project(db, project_id) @@ -1180,6 +1796,15 @@ def upload_project_documents( db.add(document) uploaded.append(document) + db.flush() + from app.services import wiki as wiki_service + + for document in uploaded: + wiki_service.ingest_project_document( + db, + document, + author_user_id=author_user_id, + ) db.commit() for document in uploaded: db.refresh(document) diff --git a/backend/app/services/project_mcp.py b/backend/app/services/project_mcp.py index 912d5f1..38af8dd 100644 --- a/backend/app/services/project_mcp.py +++ b/backend/app/services/project_mcp.py @@ -1,5 +1,7 @@ """项目数据 MCP 工具服务。""" +from datetime import date, datetime + from typing import Any from fastapi import HTTPException, status @@ -7,7 +9,7 @@ from app.models.project import Project, ProjectPlan from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MANAGER, User -from app.schemas.project import PlanCreate, PlanUpdate +from app.schemas.project import IssueCreate, IssueUpdate, PlanCreate, PlanUpdate from app.services import project as project_service @@ -50,6 +52,60 @@ "additionalProperties": False, }, }, + { + "name": "list_project_milestones", + "description": "查询指定项目的里程碑记录。", + "input_schema": { + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "项目ID"}, + }, + "required": ["project_id"], + "additionalProperties": False, + }, + }, + { + "name": "get_project_critical_path", + "description": "读取项目关键路径、关键任务和总浮动时间。", + "input_schema": { + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "项目ID"}, + }, + "required": ["project_id"], + "additionalProperties": False, + }, + }, + { + "name": "get_project_risks", + "description": "执行项目预警规则,返回总体风险等级、规则命中项、事实依据和建议动作。分析项目风险时应优先调用。", + "input_schema": { + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "项目ID"}, + "as_of": { + "type": "string", + "description": "可选评估日期,格式 YYYY-MM-DD;默认今天", + }, + }, + "required": ["project_id"], + "additionalProperties": False, + }, + }, + { + "name": "get_project_weekly_inputs", + "description": "按指定周期聚合计划、里程碑、需求、问题、知识资料、关键路径和数据缺口。只返回候选事实,不批准周报。", + "input_schema": { + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "项目ID"}, + "period_start": {"type": "string", "description": "周期开始日期,格式 YYYY-MM-DD"}, + "period_end": {"type": "string", "description": "周期结束日期,格式 YYYY-MM-DD"}, + }, + "required": ["project_id", "period_start", "period_end"], + "additionalProperties": False, + }, + }, { "name": "get_project_plan", "description": "按计划 ID 查询单条项目计划记录。用于审核、更新或解释当前选中的计划。", @@ -146,6 +202,126 @@ "additionalProperties": False, }, }, + { + "name": "search_project_knowledge", + "description": "检索指定项目的编译 Wiki 页面以及会议纪要、开发细节、聊天记录和其他原始知识资料。", + "input_schema": { + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "项目ID"}, + "query": {"type": "string", "description": "标题、正文或标签关键词"}, + "source_type": { + "type": "string", + "enum": [ + "meeting_notes", + "development_detail", + "chat_record", + "document", + "other", + ], + "description": "可选来源类型", + }, + "occurred_from": { + "type": "string", + "description": "资料发生时间起点,ISO 8601 格式", + }, + "occurred_to": { + "type": "string", + "description": "资料发生时间终点,ISO 8601 格式", + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "description": "最大返回数量,默认10", + }, + }, + "required": ["project_id"], + "additionalProperties": False, + }, + }, + { + "name": "get_weekly_report_context", + "description": "读取周报周期、状态、统计数量和生成约束。", + "input_schema": { + "type": "object", + "properties": { + "report_id": {"type": "integer", "description": "周报ID"}, + }, + "required": ["report_id"], + "additionalProperties": False, + }, + }, + { + "name": "list_weekly_report_facts", + "description": "读取周报事实底稿,可按审核状态筛选。", + "input_schema": { + "type": "object", + "properties": { + "report_id": {"type": "integer", "description": "周报ID"}, + "review_status": { + "type": "string", + "enum": [ + "verified", + "pending_confirmation", + "needs_judgment", + "blocked_from_report" + ], + "description": "可选审核状态", + }, + }, + "required": ["report_id"], + "additionalProperties": False, + }, + }, + { + "name": "get_weekly_report_evidence", + "description": "读取单条周报事实的来源证据。", + "input_schema": { + "type": "object", + "properties": { + "fact_id": {"type": "integer", "description": "周报事实ID"}, + }, + "required": ["fact_id"], + "additionalProperties": False, + }, + }, + { + "name": "create_project_issue", + "description": "为指定项目新增问题记录。需要管理员或经理权限。", + "input_schema": { + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "项目ID"}, + "title": {"type": "string", "description": "问题标题"}, + "description": {"type": "string", "description": "问题描述"}, + "severity": {"type": "string", "description": "严重程度"}, + "status": {"type": "string", "description": "问题状态"}, + "assignee": {"type": "string", "description": "指派人"}, + "resolution": {"type": "string", "description": "解决方案"}, + }, + "required": ["project_id", "title"], + "additionalProperties": False, + }, + }, + { + "name": "update_project_issue", + "description": "更新项目问题记录。需要管理员或经理权限。", + "input_schema": { + "type": "object", + "properties": { + "issue_id": {"type": "integer", "description": "问题ID"}, + "title": {"type": "string", "description": "问题标题"}, + "description": {"type": "string", "description": "问题描述"}, + "severity": {"type": "string", "description": "严重程度"}, + "status": {"type": "string", "description": "问题状态"}, + "assignee": {"type": "string", "description": "指派人"}, + "resolution": {"type": "string", "description": "解决方案"}, + }, + "required": ["issue_id"], + "additionalProperties": False, + }, + }, ] @@ -154,8 +330,9 @@ def list_tools() -> list[dict[str, Any]]: return PROJECT_MCP_TOOLS -def list_openai_tools() -> list[dict[str, Any]]: +def list_openai_tools(tool_names: list[str] | None = None) -> list[dict[str, Any]]: """返回可供 Chat Completions function calling 使用的工具定义。""" + allowed_names = set(tool_names or []) return [ { "type": "function", @@ -166,6 +343,7 @@ def list_openai_tools() -> list[dict[str, Any]]: }, } for tool in PROJECT_MCP_TOOLS + if not allowed_names or tool["name"] in allowed_names ] @@ -195,6 +373,54 @@ def call_tool( plans = project_service.list_plans(db, _required_int(args, "project_id")) return {"plans": [_serialize_plan(plan) for plan in plans]} + if name == "list_project_milestones": + milestones = project_service.list_milestones(db, _required_int(args, "project_id")) + return { + "milestones": [ + { + "id": item.id, + "project_id": item.project_id, + "name": item.name, + "description": item.description, + "planned_date": _date_to_string(item.planned_date), + "actual_date": _date_to_string(item.actual_date), + "progress_pct": item.progress_pct, + "status": item.status, + "updated_at": item.updated_at.isoformat(), + } + for item in milestones + ] + } + + if name == "get_project_critical_path": + result = project_service.calculate_critical_path( + db, + _required_int(args, "project_id"), + ) + return {"critical_path": result} + + if name == "get_project_risks": + from app.services import risk as risk_service + + as_of = _required_date(args, "as_of") if args.get("as_of") else None + report = risk_service.get_project_risk_report( + db, + _required_int(args, "project_id"), + as_of, + ) + return {"risk_report": report} + + if name == "get_project_weekly_inputs": + from app.services import weekly_report as weekly_report_service + + result = weekly_report_service.preview_weekly_inputs( + db, + _required_int(args, "project_id"), + _required_date(args, "period_start"), + _required_date(args, "period_end"), + ) + return result + if name == "get_project_plan": plan = project_service.get_plan(db, _required_int(args, "plan_id")) return {"plan": _serialize_plan(plan)} @@ -257,21 +483,107 @@ def call_tool( if name == "list_project_issues": issues = project_service.list_issues(db, _required_int(args, "project_id")) return { - "issues": [ - { - "id": item.id, - "project_id": item.project_id, - "title": item.title, - "description": item.description, - "severity": item.severity, - "status": item.status, - "assignee": item.assignee, - "resolution": item.resolution, - } - for item in issues + "issues": [_serialize_issue(item) for item in issues] + } + + if name == "search_project_knowledge": + try: + limit = int(args.get("limit", 10)) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="参数 limit 必须是整数", + ) from exc + entries = project_service.list_knowledge_entries( + db, + _required_int(args, "project_id"), + query=args.get("query"), + source_type=args.get("source_type"), + occurred_from=_optional_datetime(args, "occurred_from"), + occurred_to=_optional_datetime(args, "occurred_to"), + limit=max(1, min(limit, 20)), + ) + wiki_pages = [] + if not args.get("source_type"): + from app.services import wiki as wiki_service + + wiki_pages = wiki_service.list_wiki_pages( + db, + _required_int(args, "project_id"), + query=args.get("query"), + limit=max(1, min(limit, 20)), + ) + return { + "wiki_pages": [ + _serialize_wiki_page(page) for page in wiki_pages + ], + "knowledge_entries": [ + _serialize_knowledge_entry(entry) for entry in entries ] } + if name == "get_weekly_report_context": + from app.services import weekly_report as weekly_report_service + + report = weekly_report_service.get_report(db, _required_int(args, "report_id")) + payload = weekly_report_service.serialize_report(report, include_detail=False) + payload["rules"] = { + "verified_facts_only": True, + "ai_cannot_approve": True, + "date_types": ["planned", "forecast", "committed", "actual"], + } + return {"weekly_report": payload} + + if name == "list_weekly_report_facts": + from app.services import weekly_report as weekly_report_service + + report = weekly_report_service.get_report(db, _required_int(args, "report_id")) + review_status = args.get("review_status") + facts = [ + weekly_report_service.serialize_fact(fact) + for fact in report.facts + if not review_status or fact.review_status == review_status + ] + return {"facts": facts} + + if name == "get_weekly_report_evidence": + from app.services import weekly_report as weekly_report_service + + fact = weekly_report_service.get_fact(db, _required_int(args, "fact_id")) + serialized = weekly_report_service.serialize_fact(fact) + return {"fact_id": fact.id, "evidence": serialized["evidence"]} + + if name == "create_project_issue": + _ensure_can_manage(current_user) + project_id = _required_int(args, "project_id") + data = IssueCreate.model_validate( + {key: value for key, value in args.items() if key != "project_id"} + ) + issue = project_service.create_issue( + db, + project_id=project_id, + title=data.title, + description=data.description, + severity=data.severity, + status=data.status, + assignee=data.assignee, + resolution=data.resolution, + ) + return {"issue": _serialize_issue(issue)} + + if name == "update_project_issue": + _ensure_can_manage(current_user) + issue_id = _required_int(args, "issue_id") + data = IssueUpdate.model_validate( + {key: value for key, value in args.items() if key != "issue_id"} + ) + issue = project_service.update_issue( + db, + issue_id=issue_id, + update_data=data.model_dump(exclude_unset=True), + ) + return {"issue": _serialize_issue(issue)} + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"MCP 工具 {name} 不存在", @@ -304,6 +616,42 @@ def _required_int(arguments: dict[str, Any], key: str) -> int: ) from exc +def _required_date(arguments: dict[str, Any], key: str) -> date: + """读取必填 ISO 日期参数。""" + value = arguments.get(key) + if not isinstance(value, str) or not value.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"缺少参数 {key}", + ) + try: + return date.fromisoformat(value) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"参数 {key} 必须是 YYYY-MM-DD 日期", + ) from exc + + +def _optional_datetime(arguments: dict[str, Any], key: str) -> datetime | None: + """读取可选 ISO 时间参数。""" + value = arguments.get(key) + if value is None or value == "": + return None + if not isinstance(value, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"参数 {key} 必须是 ISO 8601 时间", + ) + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"参数 {key} 必须是 ISO 8601 时间", + ) from exc + + def _serialize_project(project: Project, include_counts: bool = False) -> dict[str, Any]: """序列化项目数据。""" data: dict[str, Any] = { @@ -353,6 +701,56 @@ def _serialize_plan(plan: ProjectPlan) -> dict[str, Any]: } +def _serialize_issue(issue: Any) -> dict[str, Any]: + """序列化问题数据。""" + return { + "id": issue.id, + "project_id": issue.project_id, + "title": issue.title, + "description": issue.description, + "severity": issue.severity, + "status": issue.status, + "assignee": issue.assignee, + "resolution": issue.resolution, + } + + +def _serialize_knowledge_entry(entry: Any) -> dict[str, Any]: + """序列化供模型使用的知识条目,并限制单条正文长度。""" + content_limit = 6000 + content = entry.content or "" + truncated = len(content) > content_limit + return { + "id": entry.id, + "project_id": entry.project_id, + "title": entry.title, + "source_type": entry.source_type, + "content": content[:content_limit], + "content_truncated": truncated, + "source_uri": entry.source_uri, + "external_id": entry.external_id, + "tags": project_service.deserialize_knowledge_tags(entry.tags_json), + "occurred_at": _date_to_string(entry.occurred_at), + } + + +def _serialize_wiki_page(page: Any) -> dict[str, Any]: + """序列化供模型优先使用的编译 Wiki 页面。""" + content_limit = 8000 + content = page.content_markdown or "" + return { + "id": page.id, + "project_id": page.project_id, + "slug": page.slug, + "page_type": page.page_type, + "title": page.title, + "summary": page.summary, + "content_markdown": content[:content_limit], + "content_truncated": len(content) > content_limit, + "revision": page.revision, + } + + def _date_to_string(value: Any) -> str | None: """将日期转为字符串。""" if value is None: diff --git a/backend/app/services/risk.py b/backend/app/services/risk.py new file mode 100644 index 0000000..7628513 --- /dev/null +++ b/backend/app/services/risk.py @@ -0,0 +1,411 @@ +"""项目风险预警规则服务。""" + +from datetime import date, datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from app.models.project import Project, ProjectPlan +from app.services import project as project_service + + +RULES_VERSION = "2026.08.1" +COMPLETED_STATUSES = { + "已完成", + "完成", + "已关闭", + "关闭", + "已解决", + "取消", + "已取消", + "closed", + "done", + "resolved", + "cancelled", +} +CRITICAL_SEVERITIES = {"严重", "高", "关键", "致命", "critical", "high"} +PENDING_REQUIREMENT_STATUSES = {"待评审", "待确认", "待处理", "未开始", "open", "pending"} +LEVEL_ORDER = {"高": 0, "中": 1, "低": 2} +LEVEL_SCORE = {"高": 30, "中": 12, "低": 4} + + +def get_project_risk_report( + db: Session, + project_id: int, + as_of: date | None = None, +) -> dict[str, Any]: + """计算指定项目的可解释风险报告。""" + project = project_service.get_project(db, project_id) + return evaluate_project_risks(project, as_of) + + +def evaluate_project_risks( + project: Project, + as_of: date | None = None, +) -> dict[str, Any]: + """基于已加载的项目数据执行风险规则。""" + evaluation_date = as_of or datetime.now(timezone.utc).date() + items: list[dict[str, Any]] = [] + + _check_project_deadline(project, evaluation_date, items) + _check_plans(project, evaluation_date, items) + _check_dependencies(project, evaluation_date, items) + _check_milestones(project, evaluation_date, items) + _check_issues(project, items) + _check_requirements(project, evaluation_date, items) + + items.sort( + key=lambda item: ( + LEVEL_ORDER[item["level"]], + item.get("due_date") or date.max, + item["code"], + item.get("entity_id") or 0, + ) + ) + high_count = sum(item["level"] == "高" for item in items) + medium_count = sum(item["level"] == "中" for item in items) + low_count = sum(item["level"] == "低" for item in items) + overall_level = "高" if high_count else "中" if medium_count else "低" + + return { + "project_id": project.id, + "project_name": project.name, + "as_of": evaluation_date, + "rules_version": RULES_VERSION, + "overall_level": overall_level, + "score": min(100, sum(LEVEL_SCORE[item["level"]] for item in items)), + "total_count": len(items), + "high_count": high_count, + "medium_count": medium_count, + "low_count": low_count, + "items": items, + } + + +def _check_project_deadline( + project: Project, + as_of: date, + items: list[dict[str, Any]], +) -> None: + """检查项目总交付日期。""" + if not project.planned_end_date or _is_completed(project.status): + return + days_to_due = (project.planned_end_date - as_of).days + if days_to_due >= 0: + return + items.append( + _risk_item( + code="PROJECT_DEADLINE_OVERDUE", + category="进度", + level="高", + title="项目计划交付日期已逾期", + description=f"项目已超过计划结束日期 {-days_to_due} 天,当前状态为“{project.status}”。", + evidence=[ + f"计划结束日期:{project.planned_end_date.isoformat()}", + f"评估日期:{as_of.isoformat()}", + f"项目状态:{project.status}", + ], + recommendation="由项目负责人确认新的承诺日期,并同步关键路径和客户影响。", + entity_type="project", + entity_id=project.id, + due_date=project.planned_end_date, + days_to_due=days_to_due, + ) + ) + + +def _check_plans( + project: Project, + as_of: date, + items: list[dict[str, Any]], +) -> None: + """检查计划逾期、临期、停滞和责任人缺失。""" + for plan in project.plans or []: + if plan.is_summary or _is_completed(plan.status) or plan.progress_pct >= 100: + continue + plan_name = _plan_name(plan) + days_to_due = ( + (plan.planned_end - as_of).days if plan.planned_end else None + ) + + if days_to_due is not None and days_to_due < 0: + overdue_days = -days_to_due + level = "高" if overdue_days >= 7 else "中" + items.append( + _risk_item( + code="PLAN_OVERDUE", + category="进度", + level=level, + title=f"计划逾期:{plan_name}", + description=f"计划已逾期 {overdue_days} 天,当前进度 {plan.progress_pct}%。", + evidence=[ + f"计划结束日期:{plan.planned_end.isoformat()}", + f"当前进度:{plan.progress_pct}%", + f"计划状态:{plan.status}", + ], + recommendation="确认阻塞原因、剩余工作量和新的完成日期;必要时调整后续依赖。", + entity_type="plan", + entity_id=plan.id, + due_date=plan.planned_end, + days_to_due=days_to_due, + ) + ) + elif days_to_due is not None and days_to_due <= 7 and plan.progress_pct < 80: + level = "高" if days_to_due <= 2 and plan.progress_pct < 50 else "中" + items.append( + _risk_item( + code="PLAN_DUE_SOON", + category="进度", + level=level, + title=f"计划临期但进度不足:{plan_name}", + description=f"计划还有 {days_to_due} 天到期,当前进度仅 {plan.progress_pct}%。", + evidence=[ + f"计划结束日期:{plan.planned_end.isoformat()}", + f"当前进度:{plan.progress_pct}%", + f"剩余时间:{days_to_due} 天", + ], + recommendation="核实剩余任务与资源,明确能否按期完成,并准备延期处置方案。", + entity_type="plan", + entity_id=plan.id, + due_date=plan.planned_end, + days_to_due=days_to_due, + ) + ) + + stale_days = _days_since(plan.updated_at, as_of) + if _has_started(plan) and 0 < plan.progress_pct < 100 and stale_days >= 10: + items.append( + _risk_item( + code="PLAN_PROGRESS_STALE", + category="进度", + level="中", + title=f"计划进度长时间未更新:{plan_name}", + description=f"计划已 {stale_days} 天没有更新,无法确认当前进展是否真实。", + evidence=[ + f"最后更新时间:{plan.updated_at.date().isoformat()}", + f"当前进度:{plan.progress_pct}%", + f"计划状态:{plan.status}", + ], + recommendation="请负责人更新进度、阻塞和预计完成日期。", + entity_type="plan", + entity_id=plan.id, + due_date=plan.planned_end, + days_to_due=days_to_due, + ) + ) + + if not (plan.assignee or "").strip(): + level = "中" if days_to_due is not None and days_to_due <= 7 else "低" + items.append( + _risk_item( + code="PLAN_OWNER_MISSING", + category="责任", + level=level, + title=f"计划缺少负责人:{plan_name}", + description="未完成计划没有明确负责人,后续跟进和升级路径不清晰。", + evidence=[ + "负责人:未填写", + f"计划状态:{plan.status}", + f"当前进度:{plan.progress_pct}%", + ], + recommendation="为计划指定唯一负责人,并确认其交付承诺。", + entity_type="plan", + entity_id=plan.id, + due_date=plan.planned_end, + days_to_due=days_to_due, + ) + ) + + +def _check_dependencies( + project: Project, + as_of: date, + items: list[dict[str, Any]], +) -> None: + """检查已经启动但前置任务未完成的计划。""" + for link in project.plan_dependencies or []: + predecessor = link.predecessor + successor = link.successor + if not predecessor or not successor: + continue + if _is_completed(predecessor.status) or predecessor.progress_pct >= 100: + continue + if not _has_started(successor): + continue + predecessor_overdue = bool( + predecessor.planned_end and predecessor.planned_end < as_of + ) + level = "高" if predecessor_overdue else "中" + evidence = [ + f"前置计划:{_plan_name(predecessor)}({predecessor.progress_pct}%)", + f"后续计划:{_plan_name(successor)}({successor.progress_pct}%)", + f"依赖类型:{link.dependency_type},延迟:{link.lag_days} 天", + ] + items.append( + _risk_item( + code="PLAN_DEPENDENCY_BLOCKED", + category="依赖", + level=level, + title=f"前置未完成但后续已启动:{_plan_name(successor)}", + description="计划执行顺序与结构化依赖不一致,可能造成返工或交付质量风险。", + evidence=evidence, + recommendation="确认是否允许并行;如不允许,暂停后续任务并升级前置阻塞。", + entity_type="plan", + entity_id=successor.id, + due_date=successor.planned_end, + days_to_due=(successor.planned_end - as_of).days if successor.planned_end else None, + ) + ) + + +def _check_milestones( + project: Project, + as_of: date, + items: list[dict[str, Any]], +) -> None: + """检查逾期里程碑。""" + for milestone in project.milestones or []: + if ( + not milestone.planned_date + or milestone.planned_date >= as_of + or _is_completed(milestone.status) + or milestone.progress_pct >= 100 + ): + continue + days_to_due = (milestone.planned_date - as_of).days + items.append( + _risk_item( + code="MILESTONE_OVERDUE", + category="进度", + level="高", + title=f"里程碑逾期:{milestone.name}", + description=f"里程碑已逾期 {-days_to_due} 天,当前进度 {milestone.progress_pct}%。", + evidence=[ + f"计划日期:{milestone.planned_date.isoformat()}", + f"当前进度:{milestone.progress_pct}%", + f"里程碑状态:{milestone.status}", + ], + recommendation="确认里程碑是否仍有效,并明确新的承诺日期和受影响交付物。", + entity_type="milestone", + entity_id=milestone.id, + due_date=milestone.planned_date, + days_to_due=days_to_due, + ) + ) + + +def _check_issues(project: Project, items: list[dict[str, Any]]) -> None: + """检查尚未关闭的严重问题。""" + for issue in project.issues or []: + if _is_completed(issue.status) or _normalize(issue.severity) not in CRITICAL_SEVERITIES: + continue + evidence = [ + f"严重程度:{issue.severity}", + f"问题状态:{issue.status}", + f"指派人:{issue.assignee or '未填写'}", + ] + items.append( + _risk_item( + code="CRITICAL_ISSUE_OPEN", + category="质量", + level="高", + title=f"严重问题未闭环:{issue.title}", + description="严重问题仍处于打开状态,可能直接影响交付或质量门禁。", + evidence=evidence, + recommendation="明确问题负责人、解决方案、验证方式和关闭日期。", + entity_type="issue", + entity_id=issue.id, + ) + ) + + +def _check_requirements( + project: Project, + as_of: date, + items: list[dict[str, Any]], +) -> None: + """检查长期未评审的高优先级需求。""" + for requirement in project.requirements or []: + if _normalize(requirement.priority) not in {"高", "紧急", "critical", "high"}: + continue + if _normalize(requirement.status) not in PENDING_REQUIREMENT_STATUSES: + continue + stale_days = _days_since(requirement.updated_at, as_of) + if stale_days < 14: + continue + items.append( + _risk_item( + code="HIGH_REQUIREMENT_PENDING", + category="范围", + level="中", + title=f"高优先级需求长期未闭环:{requirement.title}", + description=f"该需求保持“{requirement.status}”状态已至少 {stale_days} 天。", + evidence=[ + f"优先级:{requirement.priority}", + f"需求状态:{requirement.status}", + f"最后更新时间:{requirement.updated_at.date().isoformat()}", + ], + recommendation="安排需求评审,明确范围结论、负责人和进入计划的日期。", + entity_type="requirement", + entity_id=requirement.id, + ) + ) + + +def _risk_item( + *, + code: str, + category: str, + level: str, + title: str, + description: str, + evidence: list[str], + recommendation: str, + entity_type: str, + entity_id: int, + due_date: date | None = None, + days_to_due: int | None = None, +) -> dict[str, Any]: + """构造统一风险项。""" + return { + "code": code, + "category": category, + "level": level, + "title": title, + "description": description, + "evidence": evidence, + "recommendation": recommendation, + "entity_type": entity_type, + "entity_id": entity_id, + "due_date": due_date, + "days_to_due": days_to_due, + } + + +def _is_completed(status: str | None) -> bool: + """判断状态是否表示已完成或已关闭。""" + return _normalize(status) in COMPLETED_STATUSES + + +def _has_started(plan: ProjectPlan) -> bool: + """判断计划是否已经开始执行。""" + return bool( + plan.actual_start + or plan.progress_pct > 0 + or _normalize(plan.status) in {"进行中", "执行中", "in progress", "doing"} + ) + + +def _days_since(value: datetime, as_of: date) -> int: + """计算日期距评估日的天数。""" + return max(0, (as_of - value.date()).days) + + +def _normalize(value: str | None) -> str: + """归一化枚举文本。""" + return (value or "").strip().lower() + + +def _plan_name(plan: ProjectPlan) -> str: + """返回便于阅读的计划名称。""" + return plan.secondary_task or plan.primary_task or plan.phase_name diff --git a/backend/app/services/user.py b/backend/app/services/user.py index 4403f90..f94e3b8 100644 --- a/backend/app/services/user.py +++ b/backend/app/services/user.py @@ -1,6 +1,7 @@ """用户管理服务模块。""" from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models.user import USER_ROLE_ADMIN, USER_ROLES, User @@ -138,4 +139,11 @@ def delete_user(db: Session, user_id: int, current_user: User) -> None: ) db.delete(user) - db.commit() + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="该账号已有需要保留的业务或审计记录,不能删除,请改为停用账号", + ) from exc diff --git a/backend/app/services/weekly_report.py b/backend/app/services/weekly_report.py new file mode 100644 index 0000000..ac4b67f --- /dev/null +++ b/backend/app/services/weekly_report.py @@ -0,0 +1,2529 @@ +"""证据驱动的项目周报服务。""" + +import hashlib +import json +import re +from collections.abc import Iterable +from datetime import date, datetime, time, timedelta, timezone +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import HTTPException, status +from sqlalchemy import func, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, selectinload + +from app.config import settings +from app.models.project import ( + Project, + ProjectIssue, + ProjectKnowledgeEntry, + ProjectMilestone, + ProjectPlan, + ProjectPlanDependency, + ProjectRequirement, +) +from app.models.user import User +from app.models.weekly_report import ( + ProjectWeeklyReport, + ProjectWeeklyReportEvidence, + ProjectWeeklyReportFact, + ProjectWeeklyReportVersion, +) +from app.schemas.weekly_report import ( + GeneratedWeeklyReportPayload, + WeeklyReportApproveRequest, + WeeklyReportCreate, + WeeklyReportExternalFactCreate, + WeeklyReportFactCreate, +) +from app.services import agent as agent_service +from app.services import project as project_service + +REPORT_MUTABLE_STATUSES = {"collecting", "fact_review", "draft"} +SOURCE_TIMING_AVAILABLE = "available" +SOURCE_TIMING_LATE_ARRIVAL = "late_arrival" +SOURCE_TIMING_POST_CUTOFF_CHANGE = "post_cutoff_change" +FACT_REVIEW_STATUSES = { + "verified", + "pending_confirmation", + "needs_judgment", + "blocked_from_report", +} +BUSINESS_STATUSES = {"正常", "关注", "风险", "阻塞", "已完成"} +DATE_TYPES = {"planned", "forecast", "committed", "actual"} +PROMPT_TEMPLATE_KEY = "weekly_report_canonical" +PROMPT_VERSION = "1.0" +MAX_GENERATION_FACTS = 80 +VAGUE_PHRASES = ("持续推进", "基本正常", "问题不大") +WEEKLY_REPORT_SECTION_HEADINGS = ( + "本周重点", + "计划进展", + "阻塞和风险", + "需要决策", + "下周动作", +) +COVERAGE_FIELDS = ( + "plan_count", + "milestone_count", + "requirement_count", + "issue_count", + "knowledge_count", + "critical_plan_count", + "missing_assignee_count", + "missing_date_count", + "late_arrival_source_count", + "post_cutoff_source_count", + "critical_path_unavailable_count", + "truncated_source_count", +) + + +def list_reports(db: Session, project_id: int) -> list[ProjectWeeklyReport]: + """获取项目周报列表。""" + project_service.get_project(db, project_id) + return ( + db.query(ProjectWeeklyReport) + .filter(ProjectWeeklyReport.project_id == project_id) + .options( + selectinload(ProjectWeeklyReport.facts).selectinload( + ProjectWeeklyReportFact.evidence + ), + selectinload(ProjectWeeklyReport.versions), + ) + .order_by( + ProjectWeeklyReport.week_start.desc(), + ProjectWeeklyReport.id.desc(), + ) + .all() + ) + + +def get_report(db: Session, report_id: int) -> ProjectWeeklyReport: + """获取周报详情。""" + report = ( + db.query(ProjectWeeklyReport) + .filter(ProjectWeeklyReport.id == report_id) + .options( + selectinload(ProjectWeeklyReport.facts).selectinload( + ProjectWeeklyReportFact.evidence + ), + selectinload(ProjectWeeklyReport.versions), + ) + .first() + ) + if report is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"周报ID {report_id} 不存在", + ) + return report + + +def create_report( + db: Session, + project_id: int, + user: User, + data: WeeklyReportCreate, +) -> ProjectWeeklyReport: + """创建项目周报周期。""" + project = project_service.get_project(db, project_id) + period_start_at, period_end_at = _period_utc_bounds( + data.week_start, + data.week_end, + data.timezone, + ) + cutoff_at = _normalize_report_cutoff( + data.cutoff_at, + data.timezone, + min(period_end_at, datetime.now(timezone.utc)), + ) + if cutoff_at < period_start_at: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="数据截止时间不能早于周报周期开始时间", + ) + report = ProjectWeeklyReport( + project_id=project_id, + week_start=data.week_start, + week_end=data.week_end, + timezone=data.timezone, + cutoff_at=cutoff_at, + title=data.title or f"{project.name} {data.week_start:%Y-%m-%d} 至 {data.week_end:%Y-%m-%d} 周报", + status="collecting", + prompt_template_key=PROMPT_TEMPLATE_KEY, + prompt_version=PROMPT_VERSION, + created_by=user.id, + ) + db.add(report) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="当前项目的相同周报周期已经存在", + ) from exc + return get_report(db, report.id) + + +def preview_weekly_inputs( + db: Session, + project_id: int, + period_start: date, + period_end: date, + timezone_name: str = "Asia/Shanghai", + cutoff_at: datetime | None = None, +) -> dict[str, Any]: + """按周期确定性聚合周报候选事实,不写入数据库。""" + if period_end < period_start: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="统计结束日期不能早于开始日期", + ) + project = project_service.get_project(db, project_id) + next_period_end = period_end + timedelta(days=7) + period_start_at, default_period_end_at = _period_utc_bounds( + period_start, + period_end, + timezone_name, + ) + effective_cutoff = _normalize_utc_datetime(cutoff_at) or min( + default_period_end_at, + datetime.now(timezone.utc), + ) + effective_period_end_at = min(default_period_end_at, effective_cutoff) + historical_baseline_import = _is_historical_baseline_import( + project, + effective_cutoff, + timezone_name, + ) + previous_values = _previous_fact_values(db, project_id, period_start) + candidates: list[dict[str, Any]] = [] + late_arrival_sources: set[str] = set() + + all_plans = project_service.list_plans(db, project_id) + plan_timings = { + plan.id: _classify_source_timing( + plan.created_at, + plan.updated_at, + effective_cutoff, + allow_late_arrival=historical_baseline_import, + ) + for plan in all_plans + } + plans = [ + plan + for plan in all_plans + if plan_timings[plan.id] != SOURCE_TIMING_POST_CUTOFF_CHANGE + ] + post_cutoff_plan_count = sum( + timing == SOURCE_TIMING_POST_CUTOFF_CHANGE for timing in plan_timings.values() + ) + dependencies_recorded_after_cutoff = ( + db.query(ProjectPlanDependency) + .filter( + ProjectPlanDependency.project_id == project_id, + ProjectPlanDependency.created_at > effective_cutoff, + ) + .all() + ) + late_dependencies = dependencies_recorded_after_cutoff if historical_baseline_import else [] + post_cutoff_dependency_count = ( + 0 if historical_baseline_import else len(dependencies_recorded_after_cutoff) + ) + post_cutoff_source_count = post_cutoff_plan_count + post_cutoff_dependency_count + missing_assignee_count = sum(1 for plan in plans if not (plan.assignee or "").strip()) + missing_date_count = sum( + 1 for plan in plans if plan.planned_start is None or plan.planned_end is None + ) + relevant_plan_ids: set[int] = set() + + for plan in plans: + is_late_arrival = plan_timings[plan.id] == SOURCE_TIMING_LATE_ARRIVAL + source_mode = "structured_late_arrival" if is_late_arrival else "structured" + snapshot = _plan_snapshot(plan) + source_hash = _snapshot_hash(snapshot) + title = _plan_title(plan) + updated_in_period = _datetime_in_utc_range( + plan.updated_at, + period_start_at, + effective_period_end_at, + ) + actual_start_in_period = _date_in_period(plan.actual_start, period_start, period_end) + actual_end_in_period = _date_in_period(plan.actual_end, period_start, period_end) + actual_in_period = actual_start_in_period or actual_end_in_period + planned_overlap = _ranges_overlap( + plan.planned_start, + plan.planned_end, + period_start, + period_end, + ) + completed = _plan_is_completed(plan) + overdue = plan.planned_end is not None and plan.planned_end < period_end and not completed + + if actual_in_period or updated_in_period or planned_overlap: + relevant_plan_ids.add(plan.id) + if is_late_arrival and actual_end_in_period: + statement = ( + f"补录资料显示计划“{title}”的实际完成日期为 {plan.actual_end.isoformat()};" + "该资料录入晚于截止时间,需核验截止时状态。" + ) + elif is_late_arrival and actual_start_in_period: + statement = ( + f"补录资料显示计划“{title}”的实际开始日期为 {plan.actual_start.isoformat()};" + "该资料录入晚于截止时间,需核验截止时状态。" + ) + elif is_late_arrival: + statement = ( + f"补录基线显示计划“{title}”当前状态为{plan.status},进度 {plan.progress_pct}%;" + "需核验其在数据截止时的状态。" + ) + elif actual_end_in_period: + statement = f"计划“{title}”于 {plan.actual_end.isoformat()} 完成,当前进度 {plan.progress_pct}% 。" + elif completed: + statement = ( + f"计划“{title}”当前标记为已完成;正式写入前需确认实际完成日期或100%进度证据。" + ) + else: + statement = ( + f"截至本期数据截止时间,计划“{title}”状态为{plan.status},当前进度 {plan.progress_pct}% 。" + ) + candidate = _candidate( + source_key=f"plan:{plan.id}:progress", + category="progress", + module=plan.phase_name, + statement=statement, + business_status="已完成" if completed else _plan_business_status(plan, period_end), + review_status="pending_confirmation", + owner=plan.assignee, + date_type=( + "actual" + if plan.actual_end or plan.actual_start + else ("planned" if plan.planned_end else None) + ), + date_value=plan.actual_end or plan.actual_start or plan.planned_end, + source_mode=source_mode, + previous_value=previous_values.get(f"plan:{plan.id}:progress"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="plan", + source_id=plan.id, + source_title=title, + source_excerpt=_plan_excerpt(plan), + source_updated_at=plan.updated_at, + source_hash=source_hash, + ), + ) + candidates.append(candidate) + + if ( + plan.planned_start is not None + and period_end < plan.planned_start <= next_period_end + and not completed + ): + relevant_plan_ids.add(plan.id) + candidates.append( + _candidate( + source_key=f"plan:{plan.id}:next", + category="next_plan", + module=plan.phase_name, + statement=( + f"补录基线显示下期计划启动“{title}”,计划开始日期为 {plan.planned_start.isoformat()};" + "请核验该计划在截止时是否已确定。" + if is_late_arrival + else f"下期计划启动“{title}”,计划开始日期为 {plan.planned_start.isoformat()}。" + ), + business_status="正常", + review_status="pending_confirmation", + owner=plan.assignee, + action_due_at=plan.planned_end, + next_action=f"按计划启动并交付“{title}”", + date_type="planned", + date_value=plan.planned_start, + source_mode=source_mode, + previous_value=previous_values.get(f"plan:{plan.id}:next"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="plan", + source_id=plan.id, + source_title=title, + source_excerpt=_plan_excerpt(plan), + source_updated_at=plan.updated_at, + source_hash=source_hash, + ), + ) + ) + + if overdue: + relevant_plan_ids.add(plan.id) + candidates.append( + _candidate( + source_key=f"plan:{plan.id}:overdue", + category="risk", + module=plan.phase_name, + statement=( + f"补录基线显示计划“{title}”的计划结束日期为 {plan.planned_end.isoformat()}," + f"当前状态为{plan.status};需核验截止时是否已逾期。" + if is_late_arrival + else f"计划“{title}”已超过计划结束日期 {plan.planned_end.isoformat()},当前状态为{plan.status}。" + ), + business_status="风险", + review_status="needs_judgment", + owner=plan.assignee, + date_type="planned", + date_value=plan.planned_end, + source_mode=source_mode, + previous_value=previous_values.get(f"plan:{plan.id}:overdue"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="plan", + source_id=plan.id, + source_title=title, + source_excerpt=_plan_excerpt(plan), + source_updated_at=plan.updated_at, + source_hash=source_hash, + ), + ) + ) + + if is_late_arrival and plan.id in relevant_plan_ids: + late_arrival_sources.add(f"plan:{plan.id}") + + all_milestones = project_service.list_milestones(db, project_id) + milestone_timings = { + milestone.id: _classify_source_timing( + milestone.created_at, + milestone.updated_at, + effective_cutoff, + allow_late_arrival=historical_baseline_import, + ) + for milestone in all_milestones + } + milestones = [ + milestone + for milestone in all_milestones + if milestone_timings[milestone.id] != SOURCE_TIMING_POST_CUTOFF_CHANGE + ] + post_cutoff_source_count += sum( + timing == SOURCE_TIMING_POST_CUTOFF_CHANGE + for timing in milestone_timings.values() + ) + relevant_milestones = [] + for milestone in milestones: + is_late_arrival = ( + milestone_timings[milestone.id] == SOURCE_TIMING_LATE_ARRIVAL + ) + relevant = ( + _date_in_period(milestone.actual_date, period_start, period_end) + or _date_in_period(milestone.planned_date, period_start, next_period_end) + or ( + _datetime_in_utc_range( + milestone.updated_at, + period_start_at, + effective_period_end_at, + ) + ) + ) + if not relevant: + continue + relevant_milestones.append(milestone) + if is_late_arrival: + late_arrival_sources.add(f"milestone:{milestone.id}") + snapshot = _milestone_snapshot(milestone) + completed = bool(milestone.actual_date) or milestone.status == "已完成" + candidates.append( + _candidate( + source_key=f"milestone:{milestone.id}:status", + category="milestone", + module="里程碑", + statement=( + f"补录基线显示里程碑“{milestone.name}”当前状态为{milestone.status}," + f"进度 {milestone.progress_pct}%;需核验其在数据截止时的状态。" + if is_late_arrival + else f"里程碑“{milestone.name}”当前状态为{milestone.status},进度 {milestone.progress_pct}% 。" + ), + business_status="已完成" if completed else "关注", + review_status="pending_confirmation", + date_type="actual" if milestone.actual_date else ("planned" if milestone.planned_date else None), + date_value=milestone.actual_date or milestone.planned_date, + source_mode=( + "structured_late_arrival" if is_late_arrival else "structured" + ), + previous_value=previous_values.get(f"milestone:{milestone.id}:status"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="milestone", + source_id=milestone.id, + source_title=milestone.name, + source_excerpt=_milestone_excerpt(milestone), + source_updated_at=milestone.updated_at, + source_hash=_snapshot_hash(snapshot), + ), + ) + ) + + all_requirements = project_service.list_requirements(db, project_id) + requirement_timings = { + item.id: _classify_source_timing( + item.created_at, + item.updated_at, + effective_cutoff, + allow_late_arrival=historical_baseline_import, + ) + for item in all_requirements + } + eligible_requirements = [ + item + for item in all_requirements + if requirement_timings[item.id] != SOURCE_TIMING_POST_CUTOFF_CHANGE + ] + post_cutoff_source_count += sum( + timing == SOURCE_TIMING_POST_CUTOFF_CHANGE + for timing in requirement_timings.values() + ) + requirements = [ + item + for item in eligible_requirements + if ( + _datetime_in_utc_range( + item.updated_at, + period_start_at, + effective_period_end_at, + ) + ) + or ( + _datetime_in_utc_range( + item.created_at, + period_start_at, + effective_period_end_at, + ) + ) + ] + for requirement in requirements: + snapshot = _requirement_snapshot(requirement) + candidates.append( + _candidate( + source_key=f"requirement:{requirement.id}:status", + category="progress", + module="需求", + statement=f"需求“{requirement.title}”当前状态为{requirement.status}。", + business_status="关注", + review_status="pending_confirmation", + owner=requirement.owner, + source_mode="structured", + previous_value=previous_values.get(f"requirement:{requirement.id}:status"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="requirement", + source_id=requirement.id, + source_title=requirement.title, + source_excerpt=_requirement_excerpt(requirement), + source_updated_at=requirement.updated_at, + source_hash=_snapshot_hash(snapshot), + ), + ) + ) + + all_issues = project_service.list_issues(db, project_id) + issue_timings = { + item.id: _classify_source_timing( + item.created_at, + item.updated_at, + effective_cutoff, + allow_late_arrival=historical_baseline_import, + ) + for item in all_issues + } + eligible_issues = [ + item + for item in all_issues + if issue_timings[item.id] != SOURCE_TIMING_POST_CUTOFF_CHANGE + ] + post_cutoff_source_count += sum( + timing == SOURCE_TIMING_POST_CUTOFF_CHANGE for timing in issue_timings.values() + ) + issues = [ + item + for item in eligible_issues + if item.status not in {"已解决", "已关闭"} + or ( + _datetime_in_utc_range( + item.updated_at, + period_start_at, + effective_period_end_at, + ) + ) + ] + for issue in issues: + is_late_arrival = issue_timings[issue.id] == SOURCE_TIMING_LATE_ARRIVAL + snapshot = _issue_snapshot(issue) + closed = issue.status in {"已解决", "已关闭"} + if is_late_arrival: + late_arrival_sources.add(f"issue:{issue.id}") + candidates.append( + _candidate( + source_key=f"issue:{issue.id}:status", + category="progress" if closed else "risk", + module="问题", + statement=( + f"补录基线记录存在未关闭问题“{issue.title}”,当前状态为{issue.status}," + f"严重程度为{issue.severity};需核验该问题在数据截止时是否已存在。" + if is_late_arrival + else f"问题“{issue.title}”当前状态为{issue.status},严重程度为{issue.severity}。" + ), + business_status="已完成" if closed else "关注", + review_status="pending_confirmation" if closed else "needs_judgment", + owner=issue.assignee, + next_action=issue.resolution if closed else None, + source_mode=( + "structured_late_arrival" if is_late_arrival else "structured" + ), + previous_value=previous_values.get(f"issue:{issue.id}:status"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="issue", + source_id=issue.id, + source_title=issue.title, + source_excerpt=_issue_excerpt(issue), + source_updated_at=issue.updated_at, + source_hash=_snapshot_hash(snapshot), + ), + ) + ) + + start_at = period_start_at + end_at = effective_period_end_at + knowledge_query = db.query(ProjectKnowledgeEntry).filter( + ProjectKnowledgeEntry.project_id == project_id, + ProjectKnowledgeEntry.occurred_at.is_not(None), + ProjectKnowledgeEntry.occurred_at >= start_at, + ProjectKnowledgeEntry.occurred_at <= end_at, + ) + post_cutoff_knowledge_count = knowledge_query.filter( + ProjectKnowledgeEntry.created_at <= effective_cutoff, + ProjectKnowledgeEntry.updated_at > effective_cutoff + ).count() + post_cutoff_source_count += post_cutoff_knowledge_count + eligible_knowledge_query = knowledge_query.filter( + or_( + ProjectKnowledgeEntry.created_at > effective_cutoff, + ProjectKnowledgeEntry.updated_at <= effective_cutoff, + ) + ) + eligible_knowledge_count = eligible_knowledge_query.count() + knowledge_entries = ( + eligible_knowledge_query + .order_by(ProjectKnowledgeEntry.occurred_at.asc(), ProjectKnowledgeEntry.id.asc()) + .limit(100) + .all() + ) + truncated_source_count = max(0, eligible_knowledge_count - len(knowledge_entries)) + for entry in knowledge_entries: + is_late_arrival = ( + _classify_source_timing( + entry.created_at, + entry.updated_at, + effective_cutoff, + allow_late_arrival=True, + ) + == SOURCE_TIMING_LATE_ARRIVAL + ) + if is_late_arrival: + late_arrival_sources.add(f"knowledge:{entry.id}") + snapshot = _knowledge_snapshot(entry) + candidates.append( + _candidate( + source_key=f"knowledge:{entry.id}:candidate", + category="knowledge", + module="知识资料", + statement=_knowledge_candidate_statement( + entry, + is_late_arrival=is_late_arrival, + ), + review_status="pending_confirmation", + source_mode=( + "knowledge_late_arrival" + if is_late_arrival + else "knowledge_extracted" + ), + previous_value=previous_values.get(f"knowledge:{entry.id}:candidate"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="knowledge", + source_id=entry.id, + source_title=entry.title, + source_excerpt=entry.content[:1200], + source_updated_at=entry.updated_at, + source_hash=_snapshot_hash(snapshot), + source_uri=entry.source_uri, + ), + ) + ) + + critical_path_unavailable_count = ( + 1 + if post_cutoff_plan_count or post_cutoff_dependency_count + else 0 + ) + critical_path = ( + {"items": []} + if critical_path_unavailable_count + else project_service.calculate_critical_path(db, project_id) + ) + critical_items = { + item["plan_id"]: item + for item in critical_path.get("items", []) + if item.get("is_critical") + } + late_dependency_used = False + for plan in plans: + critical_item = critical_items.get(plan.id) + if critical_item is None or _plan_is_completed(plan): + continue + plan_is_late_arrival = ( + plan_timings[plan.id] == SOURCE_TIMING_LATE_ARRIVAL + ) + critical_path_is_late_arrival = plan_is_late_arrival or bool(late_dependencies) + if plan_is_late_arrival: + late_arrival_sources.add(f"plan:{plan.id}") + if late_dependencies: + late_dependency_used = True + snapshot = _critical_path_snapshot(critical_item, plan) + candidates.append( + _candidate( + source_key=f"critical_path:{plan.id}", + category="risk", + module=plan.phase_name, + statement=( + f"补录基线计算显示计划“{_plan_title(plan)}”位于当前关键路径," + f"总浮动时间为 {critical_item.get('total_float', 0)} 天;" + "需核验该路径在数据截止时是否成立。" + if critical_path_is_late_arrival + else f"计划“{_plan_title(plan)}”位于当前关键路径,总浮动时间为 {critical_item.get('total_float', 0)} 天。" + ), + business_status="关注", + review_status="needs_judgment", + owner=plan.assignee, + date_type="planned" if plan.planned_end else None, + date_value=plan.planned_end, + source_mode=( + "structured_late_arrival" + if critical_path_is_late_arrival + else "structured" + ), + previous_value=previous_values.get(f"critical_path:{plan.id}"), + current_value=snapshot, + evidence=_structured_evidence( + source_type="critical_path", + source_id=plan.id, + source_title=f"关键路径:{_plan_title(plan)}", + source_excerpt=json.dumps(critical_item, ensure_ascii=False, default=str), + source_updated_at=plan.updated_at, + source_hash=_snapshot_hash(snapshot), + ), + ) + ) + + if late_dependency_used: + late_arrival_sources.update( + f"dependency:{dependency.id}" for dependency in late_dependencies + ) + + coverage = { + "plan_count": len(relevant_plan_ids), + "milestone_count": len(relevant_milestones), + "requirement_count": len(requirements), + "issue_count": len(issues), + "knowledge_count": len(knowledge_entries), + "critical_plan_count": len(critical_items), + "missing_assignee_count": missing_assignee_count, + "missing_date_count": missing_date_count, + "late_arrival_source_count": len(late_arrival_sources), + "post_cutoff_source_count": post_cutoff_source_count, + "critical_path_unavailable_count": critical_path_unavailable_count, + "truncated_source_count": truncated_source_count, + } + return {"coverage": coverage, "candidates": candidates} + + +def collect_report_inputs( + db: Session, + report_id: int, + user: User, +) -> tuple[ProjectWeeklyReport, dict[str, int], int, int]: + """采集并幂等写入周报候选事实。""" + _lock_report_for_update(db, report_id) + report = get_report(db, report_id) + _ensure_report_mutable(report) + preview = preview_weekly_inputs( + db, + report.project_id, + report.week_start, + report.week_end, + report.timezone, + report.cutoff_at, + ) + created_count = 0 + updated_count = 0 + for candidate in preview["candidates"]: + created = _upsert_candidate_fact(db, report, candidate, user.id) + if created is True: + created_count += 1 + elif created is False: + updated_count += 1 + report.status = "fact_review" + report.reviewed_by = user.id + report.coverage_json = json.dumps( + preview["coverage"], + ensure_ascii=False, + sort_keys=True, + ) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="候选事实采集发生并发冲突,请刷新后重试", + ) from exc + return get_report(db, report.id), preview["coverage"], created_count, updated_count + + +def create_manual_fact( + db: Session, + report_id: int, + user: User, + data: WeeklyReportFactCreate, +) -> ProjectWeeklyReportFact: + """人工补充待确认事实。""" + _lock_report_for_update(db, report_id) + report = get_report(db, report_id) + _ensure_report_mutable(report) + fact = ProjectWeeklyReportFact( + report_id=report.id, + category=data.category, + module=data.module, + statement=data.statement.strip(), + business_status=data.business_status, + review_status="pending_confirmation", + audience_scope=data.audience_scope, + owner=data.owner, + action_due_at=data.action_due_at, + next_action=data.next_action, + impact_type=data.impact_type, + impact_description=data.impact_description, + decision_required=data.decision_required, + decision_owner=data.decision_owner, + decision_due_at=data.decision_due_at, + date_type=data.date_type, + date_value=data.date_value, + source_mode="manual", + created_by=user.id, + ) + db.add(fact) + db.flush() + evidence_items = data.evidence or [] + if not evidence_items: + evidence_items = [ + { + "source_type": "manual", + "source_title": "人工补充说明", + "source_excerpt": data.statement.strip(), + } + ] + for item in evidence_items: + values = item if isinstance(item, dict) else item.model_dump() + db.add( + ProjectWeeklyReportEvidence( + fact_id=fact.id, + **_normalize_untrusted_evidence( + values, + source_type="manual", + source_id=None, + ), + ) + ) + report.status = "fact_review" + report.reviewed_by = user.id + db.commit() + return get_fact(db, fact.id) + + +def batch_upsert_external_facts( + db: Session, + project_id: int, + report_id: int, + facts: list[WeeklyReportExternalFactCreate], +) -> tuple[int, int, list[int]]: + """批量导入外部候选事实,并强制保持待确认状态。""" + _lock_report_for_update(db, report_id) + report = get_report(db, report_id) + if report.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="指定项目下不存在该周报", + ) + _ensure_report_mutable(report) + created_count = 0 + updated_count = 0 + fact_ids: list[int] = [] + for item in facts: + evidence = [ + _normalize_untrusted_evidence( + value.model_dump(), + source_type="external", + source_id=item.external_id, + ) + for value in item.evidence + ] + if not evidence: + evidence = [ + _normalize_untrusted_evidence( + { + "source_type": "external", + "source_id": item.external_id, + "source_title": f"外部候选事实 {item.external_id}", + "source_excerpt": item.statement, + }, + source_type="external", + source_id=item.external_id, + ) + ] + external_snapshot = { + **item.model_dump(mode="json", exclude={"evidence"}), + "evidence": evidence, + } + candidate = _candidate( + source_key=f"external:{item.external_id}", + category=item.category, + module=item.module, + statement=item.statement, + business_status=item.business_status, + review_status="pending_confirmation", + audience_scope=item.audience_scope, + owner=item.owner, + action_due_at=item.action_due_at, + next_action=item.next_action, + impact_type=item.impact_type, + impact_description=item.impact_description, + decision_required=item.decision_required, + decision_owner=item.decision_owner, + decision_due_at=item.decision_due_at, + date_type=item.date_type, + date_value=item.date_value, + source_mode="manual", + current_value=external_snapshot, + evidence=evidence, + ) + created = _upsert_candidate_fact(db, report, candidate, None) + fact = ( + db.query(ProjectWeeklyReportFact) + .filter( + ProjectWeeklyReportFact.report_id == report.id, + ProjectWeeklyReportFact.source_key == candidate["source_key"], + ) + .one() + ) + if created is not None: + fact.review_status = "pending_confirmation" + fact.verified_by = None + fact.verified_at = None + fact_ids.append(fact.id) + if created is True: + created_count += 1 + elif created is False: + updated_count += 1 + if created_count or updated_count: + report.status = "fact_review" + db.commit() + return created_count, updated_count, fact_ids + + +def update_fact( + db: Session, + fact_id: int, + user: User, + update_data: dict[str, Any], +) -> ProjectWeeklyReportFact: + """审核或修订周报事实。""" + fact = get_fact(db, fact_id) + _lock_report_for_update(db, fact.report_id) + db.expire_all() + fact = get_fact(db, fact_id) + report = get_report(db, fact.report_id) + _ensure_report_mutable(report) + allowed_fields = { + "category", + "module", + "statement", + "business_status", + "review_status", + "audience_scope", + "owner", + "action_due_at", + "next_action", + "impact_type", + "impact_description", + "decision_required", + "decision_owner", + "decision_due_at", + "date_type", + "date_value", + } + meaningful_fields = allowed_fields - {"review_status"} + changed_meaning = False + for field, value in update_data.items(): + if field not in allowed_fields: + continue + if field == "statement" and isinstance(value, str): + value = value.strip() + if getattr(fact, field) != value and field in meaningful_fields: + changed_meaning = True + setattr(fact, field, value) + + requested_review_status = update_data.get("review_status") + if requested_review_status is not None and requested_review_status not in FACT_REVIEW_STATUSES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="事实审核状态不合法", + ) + if fact.business_status is not None and fact.business_status not in BUSINESS_STATUSES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="事实业务状态不合法", + ) + if fact.date_type is not None and fact.date_type not in DATE_TYPES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="日期语义不合法", + ) + if changed_meaning and requested_review_status is None: + fact.review_status = "pending_confirmation" + if fact.review_status == "verified": + fact.verified_by = user.id + fact.verified_at = datetime.now(timezone.utc) + else: + fact.verified_by = None + fact.verified_at = None + report.reviewed_by = user.id + if report.status == "draft": + report.status = "fact_review" + db.commit() + return get_fact(db, fact.id) + + +def validate_report(db: Session, report_id: int) -> dict[str, Any]: + """执行周报批准前的确定性校验。""" + report = get_report(db, report_id) + if report.status in {"approved", "archived"}: + return { + "can_approve": True, + "blockers": [], + "warnings": ["周报已批准,校验结果以批准时冻结的事实和证据快照为准"], + } + blockers: list[str] = [] + warnings: list[str] = [] + coverage = _load_coverage_snapshot(report.coverage_json) + if coverage["late_arrival_source_count"]: + warnings.append( + f"有 {coverage['late_arrival_source_count']} 条资料在截止时间后补录," + "已作为待确认候选纳入;批准前请核验其截止时状态" + ) + if coverage["post_cutoff_source_count"]: + warnings.append( + f"有 {coverage['post_cutoff_source_count']} 条来源在数据截止时间后被改写," + "因无法还原截止时快照而未纳入本次采集" + ) + if coverage["critical_path_unavailable_count"]: + warnings.append("计划数据在截止时间后发生变化,本期关键路径候选未自动采集") + if coverage["truncated_source_count"]: + warnings.append( + f"有 {coverage['truncated_source_count']} 条知识资料因单次采集上限未纳入候选,请缩小周期" + ) + if _normalize_utc_datetime(report.cutoff_at) > datetime.now(timezone.utc): + blockers.append("数据截止时间仍在未来,不能批准周报") + verified_facts = [fact for fact in report.facts if fact.review_status == "verified"] + if not verified_facts: + blockers.append("至少需要一条已核验事实才能批准周报") + + current_hash_cache: dict[str, str | None] = {} + categories: set[str] = set() + for fact in verified_facts: + categories.add(fact.category) + label = f"事实#{fact.id}" + if not fact.evidence: + blockers.append(f"{label} 没有来源证据") + if fact.business_status == "已完成" and not _has_completion_evidence(fact): + blockers.append(f"{label} 标记为已完成,但没有实际完成日期、100%进度或人工完成证据") + if (fact.date_type is None) != (fact.date_value is None): + blockers.append(f"{label} 的日期与日期语义必须同时填写") + if fact.category == "risk": + if not (fact.owner or "").strip(): + blockers.append(f"{label} 是风险事项,但缺少负责人") + if not (fact.next_action or "").strip(): + blockers.append(f"{label} 是风险事项,但缺少下一步动作") + if not (fact.impact_description or "").strip(): + blockers.append(f"{label} 是风险事项,但缺少影响说明") + if fact.decision_required: + if not (fact.decision_owner or "").strip(): + blockers.append(f"{label} 需要决策,但缺少决策人") + if fact.decision_due_at is None: + blockers.append(f"{label} 需要决策,但缺少决策截止日期") + if not (fact.impact_description or "").strip(): + blockers.append(f"{label} 需要决策,但缺少逾期影响说明") + if fact.next_action and not fact.owner: + warnings.append(f"{label} 有下一步动作,但未填写负责人") + if fact.next_action and fact.action_due_at is None: + warnings.append(f"{label} 有下一步动作,但未填写截止日期") + for evidence in fact.evidence: + stale_reason = _evidence_stale_reason( + db, + evidence, + current_hash_cache, + report.project_id, + ) + if stale_reason: + blockers.append(f"{label} {stale_reason}") + + pending_count = sum( + 1 for fact in report.facts if fact.review_status == "pending_confirmation" + ) + judgment_count = sum(1 for fact in report.facts if fact.review_status == "needs_judgment") + if pending_count: + warnings.append(f"仍有 {pending_count} 条待确认事实未进入正式周报") + if judgment_count: + warnings.append(f"仍有 {judgment_count} 条事实需要项目经理判断") + for required_category, label in ( + ("milestone", "里程碑"), + ("risk", "风险"), + ("next_plan", "下周动作"), + ): + if required_category not in categories: + warnings.append(f"本期没有已核验的{label}事实") + return { + "can_approve": not blockers, + "blockers": list(dict.fromkeys(blockers)), + "warnings": list(dict.fromkeys(warnings)), + } + + +async def generate_report_draft( + db: Session, + report_id: int, + user: User, + *, + requested_model: str | None = None, + temporary_api_key: str | None = None, +) -> ProjectWeeklyReportVersion: + """仅基于已核验事实生成结构化 AI 草稿。""" + report = get_report(db, report_id) + _ensure_report_mutable(report) + verified_facts = [fact for fact in report.facts if fact.review_status == "verified"] + if not verified_facts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请先核验至少一条事实,再生成周报草稿", + ) + if len(verified_facts) > MAX_GENERATION_FACTS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"已核验事实超过 {MAX_GENERATION_FACTS} 条,请缩小周期或排除无关事实", + ) + validation = validate_report(db, report.id) + if validation["blockers"]: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "事实底稿存在阻断项,不能生成可信草稿", + "blockers": validation["blockers"], + }, + ) + facts_payload = [_fact_for_model(fact) for fact in verified_facts] + setting = agent_service.get_or_create_setting(db, user) + api_key = temporary_api_key or setting.api_key + if not api_key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请先在智能助理设置中填写 DeepSeek API Key", + ) + model = requested_model or setting.model or settings.AGENT_DEFAULT_MODEL + messages = _build_generation_messages(report, facts_payload) + raw_output, normalized_model = await agent_service.request_plain_completion( + api_base_url=setting.api_base_url or settings.AGENT_DEFAULT_API_BASE_URL, + api_key=api_key, + model=model, + messages=messages, + ) + generated = _parse_generated_payload(raw_output) + _validate_generated_structure(generated) + allowed_fact_ids = {fact.id for fact in verified_facts} + referenced_fact_ids = _validate_generated_fact_ids(generated, allowed_fact_ids) + facts_by_id = {fact.id: fact for fact in verified_facts} + _validate_generated_semantics(generated, facts_by_id) + content_markdown = _render_generated_markdown(generated, report.title) + locked_report = _lock_report_for_update(db, report.id) + _ensure_report_mutable(locked_report) + version = ProjectWeeklyReportVersion( + report_id=report.id, + audience_type="canonical", + version_no=_next_version_number(db, report.id, "canonical"), + version_stage="ai_draft", + content_markdown=content_markdown, + raw_output=raw_output, + fact_ids_json=json.dumps(sorted(referenced_fact_ids)), + fact_hashes_json=_dump_fact_hashes( + facts_by_id[fact_id] + for fact_id in referenced_fact_ids + ), + prompt_version=PROMPT_VERSION, + model_name=normalized_model, + generated_by=user.id, + generated_at=datetime.now(timezone.utc), + ) + db.add(version) + report.status = "draft" + report.model_name = normalized_model + report.reviewed_by = user.id + db.commit() + return _get_version(db, version.id) + + +def save_draft_version( + db: Session, + version_id: int, + user: User, + content_markdown: str, +) -> ProjectWeeklyReportVersion: + """把人工编辑后的内容保存为新的草稿版本。""" + source_version = _get_version(db, version_id) + _lock_report_for_update(db, source_version.report_id) + report = get_report(db, source_version.report_id) + _ensure_report_mutable(report) + cited_fact_ids = _extract_fact_ids(content_markdown) + source_hashes = _load_json_string_map(source_version.fact_hashes_json) + current_facts = { + fact.id: fact + for fact in report.facts + if fact.review_status == "verified" + } + draft_hashes: dict[str, str] = {} + for fact_id in cited_fact_ids: + source_hash = source_hashes.get(str(fact_id)) + if source_hash is not None: + draft_hashes[str(fact_id)] = source_hash + elif fact_id in current_facts: + draft_hashes[str(fact_id)] = _fact_snapshot_hash(current_facts[fact_id]) + version = ProjectWeeklyReportVersion( + report_id=report.id, + audience_type=source_version.audience_type, + version_no=_next_version_number(db, report.id, source_version.audience_type), + version_stage="human_draft", + content_markdown=content_markdown.strip(), + raw_output=None, + fact_ids_json=json.dumps(sorted(cited_fact_ids)), + fact_hashes_json=json.dumps(draft_hashes, ensure_ascii=False, sort_keys=True), + prompt_version=source_version.prompt_version, + model_name=source_version.model_name, + generated_by=user.id, + generated_at=datetime.now(timezone.utc), + ) + db.add(version) + report.status = "draft" + report.reviewed_by = user.id + db.commit() + return _get_version(db, version.id) + + +def approve_report( + db: Session, + report_id: int, + user: User, + data: WeeklyReportApproveRequest, +) -> ProjectWeeklyReport: + """人工批准周报终稿并冻结正式版本。""" + _lock_report_for_update(db, report_id) + report = get_report(db, report_id) + _ensure_report_mutable(report) + source_version = _get_version(db, data.source_version_id) + if source_version.report_id != report.id or source_version.audience_type != "canonical": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="待批准草稿不属于当前周报", + ) + validation = validate_report(db, report.id) + blockers = list(validation["blockers"]) + referenced_ids = set(_load_json_list(source_version.fact_ids_json)) + verified_facts = { + fact.id: fact + for fact in report.facts + if fact.review_status == "verified" + } + verified_ids = set(verified_facts) + source_hashes = _load_json_string_map(source_version.fact_hashes_json) + if not referenced_ids: + blockers.append("待批准草稿没有引用任何已核验事实") + if not referenced_ids.issubset(verified_ids): + blockers.append("待批准草稿引用了未核验或已失效的事实") + content_blockers, cited_ids = _validate_approved_content( + data.content_markdown, + verified_facts, + report.title, + ) + blockers.extend(content_blockers) + added_ids = cited_ids - referenced_ids + if added_ids: + blockers.append( + f"人工终稿新增了草稿版本未冻结的事实ID:{sorted(added_ids)},请先保存为新修订版" + ) + for fact_id in cited_ids & verified_ids: + frozen_hash = source_hashes.get(str(fact_id)) + if frozen_hash is None: + blockers.append(f"待批准草稿缺少事实#{fact_id}的冻结快照,请重新生成草稿") + elif frozen_hash != _fact_snapshot_hash(verified_facts[fact_id]): + blockers.append(f"事实#{fact_id}在草稿生成后已变化,请基于最新事实重新生成草稿") + if blockers: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": "周报存在阻断项,不能批准", "blockers": list(dict.fromkeys(blockers))}, + ) + now = datetime.now(timezone.utc) + final_version = ProjectWeeklyReportVersion( + report_id=report.id, + audience_type="canonical", + version_no=_next_version_number(db, report.id, "canonical"), + version_stage="approved_final", + content_markdown=data.content_markdown.strip(), + raw_output=None, + fact_ids_json=json.dumps(sorted(cited_ids)), + fact_hashes_json=json.dumps( + {str(fact_id): source_hashes[str(fact_id)] for fact_id in sorted(cited_ids)}, + ensure_ascii=False, + sort_keys=True, + ), + prompt_version=source_version.prompt_version, + model_name=source_version.model_name, + generated_by=user.id, + generated_at=now, + approved_by=user.id, + approved_at=now, + ) + db.add(final_version) + report.overall_status = data.overall_status + report.status = "approved" + report.reviewed_by = user.id + report.approved_by = user.id + report.approved_at = now + db.commit() + return get_report(db, report.id) + + +def archive_report(db: Session, report_id: int, user: User) -> ProjectWeeklyReport: + """归档已经批准的周报。""" + _lock_report_for_update(db, report_id) + report = get_report(db, report_id) + if report.status != "approved": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="只有已批准周报可以归档", + ) + report.status = "archived" + report.reviewed_by = user.id + report.archived_at = datetime.now(timezone.utc) + db.commit() + return get_report(db, report.id) + + +def serialize_report(report: ProjectWeeklyReport, *, include_detail: bool) -> dict[str, Any]: + """序列化周报及其统计信息。""" + facts = list(report.facts or []) + versions = list(report.versions or []) + latest_version = max(versions, key=lambda item: item.id, default=None) + payload: dict[str, Any] = { + "id": report.id, + "project_id": report.project_id, + "week_start": report.week_start, + "week_end": report.week_end, + "timezone": report.timezone, + "cutoff_at": report.cutoff_at, + "title": report.title, + "status": report.status, + "overall_status": report.overall_status, + "fact_count": len(facts), + "verified_count": sum(1 for fact in facts if fact.review_status == "verified"), + "pending_count": sum(1 for fact in facts if fact.review_status != "verified"), + "latest_version_id": latest_version.id if latest_version else None, + "created_by": report.created_by, + "approved_by": report.approved_by, + "approved_at": report.approved_at, + "archived_at": report.archived_at, + "created_at": report.created_at, + "updated_at": report.updated_at, + } + if include_detail: + payload.update( + { + "prompt_template_key": report.prompt_template_key, + "prompt_version": report.prompt_version, + "model_name": report.model_name, + "reviewed_by": report.reviewed_by, + "coverage": _load_coverage_snapshot(report.coverage_json), + "facts": [serialize_fact(fact) for fact in facts], + "versions": [serialize_version(version) for version in versions], + } + ) + return payload + + +def _load_coverage_snapshot(value: str | None) -> dict[str, int]: + """解析并规范化最近一次采集覆盖快照。""" + parsed = _load_json_object(value) or {} + return { + field: max(0, int(parsed.get(field) or 0)) + for field in COVERAGE_FIELDS + } + + +def serialize_fact(fact: ProjectWeeklyReportFact) -> dict[str, Any]: + """序列化周报事实。""" + return { + "id": fact.id, + "report_id": fact.report_id, + "source_key": fact.source_key, + "category": fact.category, + "module": fact.module, + "statement": fact.statement, + "business_status": fact.business_status, + "review_status": fact.review_status, + "audience_scope": fact.audience_scope, + "owner": fact.owner, + "action_due_at": fact.action_due_at, + "next_action": fact.next_action, + "impact_type": fact.impact_type, + "impact_description": fact.impact_description, + "decision_required": fact.decision_required, + "decision_owner": fact.decision_owner, + "decision_due_at": fact.decision_due_at, + "date_type": fact.date_type, + "date_value": fact.date_value, + "previous_value": _load_json_object(fact.previous_value_json), + "current_value": _load_json_object(fact.current_value_json), + "source_mode": fact.source_mode, + "created_by": fact.created_by, + "verified_by": fact.verified_by, + "verified_at": fact.verified_at, + "created_at": fact.created_at, + "updated_at": fact.updated_at, + "evidence": [ + { + "id": evidence.id, + "source_type": evidence.source_type, + "source_id": evidence.source_id, + "source_uri": evidence.source_uri, + "source_title": evidence.source_title, + "source_excerpt": evidence.source_excerpt, + "source_updated_at": evidence.source_updated_at, + "source_content_hash": evidence.source_content_hash, + "created_at": evidence.created_at, + } + for evidence in fact.evidence + ], + } + + +def serialize_version(version: ProjectWeeklyReportVersion) -> dict[str, Any]: + """序列化周报版本,不向浏览器返回模型原始响应。""" + return { + "id": version.id, + "report_id": version.report_id, + "audience_type": version.audience_type, + "version_no": version.version_no, + "version_stage": version.version_stage, + "content_markdown": version.content_markdown, + "fact_ids": _load_json_list(version.fact_ids_json), + "fact_hashes": _load_json_string_map(version.fact_hashes_json), + "prompt_version": version.prompt_version, + "model_name": version.model_name, + "generated_by": version.generated_by, + "generated_at": version.generated_at, + "approved_by": version.approved_by, + "approved_at": version.approved_at, + "created_at": version.created_at, + } + + +def get_fact(db: Session, fact_id: int) -> ProjectWeeklyReportFact: + """获取周报事实及其证据。""" + fact = ( + db.query(ProjectWeeklyReportFact) + .filter(ProjectWeeklyReportFact.id == fact_id) + .options(selectinload(ProjectWeeklyReportFact.evidence)) + .first() + ) + if fact is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"周报事实ID {fact_id} 不存在", + ) + return fact + + +def _get_version(db: Session, version_id: int) -> ProjectWeeklyReportVersion: + """获取周报版本。""" + version = ( + db.query(ProjectWeeklyReportVersion) + .filter(ProjectWeeklyReportVersion.id == version_id) + .first() + ) + if version is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"周报版本ID {version_id} 不存在", + ) + return version + + +def _ensure_report_mutable(report: ProjectWeeklyReport) -> None: + """确保周报尚未批准或归档。""" + if report.status not in REPORT_MUTABLE_STATUSES: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="已批准或已归档周报不可继续修改", + ) + + +def _period_utc_bounds( + period_start: date, + period_end: date, + timezone_name: str, +) -> tuple[datetime, datetime]: + """把项目本地日期边界转换为UTC时间。""" + try: + zone = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"不支持的时区:{timezone_name}", + ) from exc + start_at = datetime.combine(period_start, time.min, tzinfo=zone).astimezone(timezone.utc) + end_at = datetime.combine(period_end, time.max, tzinfo=zone).astimezone(timezone.utc) + return start_at, end_at + + +def _normalize_report_cutoff( + value: datetime | None, + timezone_name: str, + default_value: datetime, +) -> datetime: + """按周报时区解释无时区截止时间并统一转换为UTC。""" + if value is None: + return default_value + if value.tzinfo is not None: + return value.astimezone(timezone.utc) + try: + zone = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"不支持的时区:{timezone_name}", + ) from exc + return value.replace(tzinfo=zone).astimezone(timezone.utc) + + +def _normalize_utc_datetime(value: datetime | None) -> datetime | None: + """把可选时间归一化为UTC。""" + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _datetime_in_utc_range( + value: datetime | None, + start_at: datetime, + end_at: datetime, +) -> bool: + """判断时间是否落在已换算为UTC的闭区间内。""" + normalized = _normalize_utc_datetime(value) + return normalized is not None and start_at <= normalized <= end_at + + +def _is_historical_baseline_import( + project: Project, + cutoff_at: datetime, + timezone_name: str, +) -> bool: + """判断项目是否在业务已开始后才作为历史基线录入系统。""" + project_created_at = _normalize_utc_datetime(project.created_at) + cutoff_local_date = cutoff_at.astimezone(ZoneInfo(timezone_name)).date() + return bool( + project.start_date is not None + and project.start_date <= cutoff_local_date + and project_created_at is not None + and project_created_at > cutoff_at + ) + + +def _classify_source_timing( + created_at: datetime | None, + updated_at: datetime | None, + cutoff_at: datetime, + *, + allow_late_arrival: bool, +) -> str: + """区分截止时可用、历史补录和截止后真实改写。""" + normalized_created_at = _normalize_utc_datetime(created_at) + normalized_updated_at = _normalize_utc_datetime(updated_at) + if normalized_created_at is None or normalized_updated_at is None: + return SOURCE_TIMING_POST_CUTOFF_CHANGE + if normalized_created_at <= cutoff_at and normalized_updated_at <= cutoff_at: + return SOURCE_TIMING_AVAILABLE + if allow_late_arrival and normalized_created_at > cutoff_at: + return SOURCE_TIMING_LATE_ARRIVAL + return SOURCE_TIMING_POST_CUTOFF_CHANGE + + +def _previous_fact_values( + db: Session, + project_id: int, + period_start: date, +) -> dict[str, dict[str, Any]]: + """读取上一期已批准或已归档周报的事实快照。""" + previous_report = ( + db.query(ProjectWeeklyReport) + .filter( + ProjectWeeklyReport.project_id == project_id, + ProjectWeeklyReport.week_end < period_start, + ProjectWeeklyReport.status.in_(["approved", "archived"]), + ) + .order_by(ProjectWeeklyReport.week_end.desc(), ProjectWeeklyReport.id.desc()) + .first() + ) + if previous_report is None: + return {} + facts = ( + db.query(ProjectWeeklyReportFact) + .filter( + ProjectWeeklyReportFact.report_id == previous_report.id, + ProjectWeeklyReportFact.source_key.is_not(None), + ) + .all() + ) + return { + fact.source_key: _load_json_object(fact.current_value_json) or {} + for fact in facts + if fact.source_key + } + + +def _candidate(**values: Any) -> dict[str, Any]: + """构造字段完整的候选事实。""" + defaults: dict[str, Any] = { + "module": None, + "business_status": None, + "review_status": "pending_confirmation", + "audience_scope": "internal", + "owner": None, + "action_due_at": None, + "next_action": None, + "impact_type": None, + "impact_description": None, + "decision_required": False, + "decision_owner": None, + "decision_due_at": None, + "date_type": None, + "date_value": None, + "previous_value": None, + "current_value": None, + "source_mode": "structured", + "evidence": [], + } + defaults.update(values) + return defaults + + +def _structured_evidence( + *, + source_type: str, + source_id: int, + source_title: str, + source_excerpt: str, + source_updated_at: datetime | None, + source_hash: str, + source_uri: str | None = None, +) -> list[dict[str, Any]]: + """构造单条结构化证据。""" + return [ + { + "source_type": source_type, + "source_id": str(source_id), + "source_uri": source_uri, + "source_title": source_title, + "source_excerpt": source_excerpt, + "source_updated_at": source_updated_at, + "source_content_hash": source_hash, + } + ] + + +def _normalize_untrusted_evidence( + values: dict[str, Any], + *, + source_type: str, + source_id: str | None, +) -> dict[str, Any]: + """归一化人工或外部证据并由服务端计算快照哈希。""" + snapshot = { + "source_type": source_type, + "source_id": source_id, + "source_uri": values.get("source_uri"), + "source_title": values.get("source_title"), + "source_excerpt": values.get("source_excerpt"), + "source_updated_at": values.get("source_updated_at"), + } + return { + **snapshot, + "source_content_hash": _snapshot_hash(snapshot), + } + + +def _upsert_candidate_fact( + db: Session, + report: ProjectWeeklyReport, + candidate: dict[str, Any], + created_by: int | None, +) -> bool | None: + """按来源标识幂等写入候选事实。""" + source_key = candidate["source_key"] + fact = ( + db.query(ProjectWeeklyReportFact) + .filter( + ProjectWeeklyReportFact.report_id == report.id, + ProjectWeeklyReportFact.source_key == source_key, + ) + .options(selectinload(ProjectWeeklyReportFact.evidence)) + .first() + ) + current_json = json.dumps(candidate.get("current_value"), ensure_ascii=False, sort_keys=True, default=str) + previous_json = ( + json.dumps(candidate.get("previous_value"), ensure_ascii=False, sort_keys=True, default=str) + if candidate.get("previous_value") is not None + else None + ) + created = fact is None + if fact is None: + fact = ProjectWeeklyReportFact( + report_id=report.id, + source_key=source_key, + created_by=created_by, + ) + db.add(fact) + source_changed = True + else: + source_changed = fact.current_value_json != current_json + if not source_changed: + return None + + for field in ( + "category", + "module", + "statement", + "business_status", + "review_status", + "audience_scope", + "owner", + "action_due_at", + "next_action", + "impact_type", + "impact_description", + "decision_required", + "decision_owner", + "decision_due_at", + "date_type", + "date_value", + "source_mode", + ): + setattr(fact, field, candidate.get(field)) + fact.previous_value_json = previous_json + fact.current_value_json = current_json + if source_changed: + fact.review_status = candidate.get("review_status") or "pending_confirmation" + fact.verified_by = None + fact.verified_at = None + for existing in list(fact.evidence): + db.delete(existing) + db.flush() + for evidence in candidate.get("evidence") or []: + db.add(ProjectWeeklyReportEvidence(fact_id=fact.id, **evidence)) + return created + + +def _plan_title(plan: ProjectPlan) -> str: + """生成计划显示名称。""" + return plan.secondary_task or plan.primary_task or plan.phase_name + + +def _plan_is_completed(plan: ProjectPlan) -> bool: + """判断计划当前是否标记完成。""" + return bool(plan.actual_end) or plan.status == "已完成" or plan.progress_pct >= 100 + + +def _plan_business_status(plan: ProjectPlan, period_end: date) -> str: + """根据当前计划数据生成非承诺型业务状态。""" + if plan.status in {"阻塞", "已阻塞"}: + return "阻塞" + if plan.planned_end and plan.planned_end < period_end and not _plan_is_completed(plan): + return "风险" + return "关注" if plan.progress_pct < 100 else "已完成" + + +def _plan_snapshot(plan: ProjectPlan) -> dict[str, Any]: + """生成计划证据快照。""" + return { + "id": plan.id, + "task_code": plan.task_code, + "phase_name": plan.phase_name, + "primary_task": plan.primary_task, + "secondary_task": plan.secondary_task, + "status": plan.status, + "progress_pct": plan.progress_pct, + "assignee": plan.assignee, + "planned_start": plan.planned_start, + "planned_end": plan.planned_end, + "actual_start": plan.actual_start, + "actual_end": plan.actual_end, + "duration_days": plan.duration_days, + "updated_at": plan.updated_at, + } + + +def _plan_excerpt(plan: ProjectPlan) -> str: + """生成计划证据摘录。""" + return ( + f"任务:{_plan_title(plan)};状态:{plan.status};进度:{plan.progress_pct}%;" + f"负责人:{plan.assignee or '未填写'};计划:{plan.planned_start or '-'} 至 {plan.planned_end or '-'};" + f"实际:{plan.actual_start or '-'} 至 {plan.actual_end or '-'}" + ) + + +def _milestone_snapshot(milestone: ProjectMilestone) -> dict[str, Any]: + """生成里程碑证据快照。""" + return { + "id": milestone.id, + "name": milestone.name, + "status": milestone.status, + "progress_pct": milestone.progress_pct, + "planned_date": milestone.planned_date, + "actual_date": milestone.actual_date, + "description": milestone.description, + "updated_at": milestone.updated_at, + } + + +def _milestone_excerpt(milestone: ProjectMilestone) -> str: + """生成里程碑证据摘录。""" + return ( + f"里程碑:{milestone.name};状态:{milestone.status};进度:{milestone.progress_pct}%;" + f"计划日期:{milestone.planned_date or '-'};实际日期:{milestone.actual_date or '-'}" + ) + + +def _requirement_snapshot(requirement: ProjectRequirement) -> dict[str, Any]: + """生成需求证据快照。""" + return { + "id": requirement.id, + "req_id": requirement.req_id, + "title": requirement.title, + "status": requirement.status, + "priority": requirement.priority, + "owner": requirement.owner, + "description": requirement.description, + "updated_at": requirement.updated_at, + } + + +def _requirement_excerpt(requirement: ProjectRequirement) -> str: + """生成需求证据摘录。""" + return ( + f"需求:{requirement.req_id or requirement.id} {requirement.title};状态:{requirement.status};" + f"优先级:{requirement.priority};负责人:{requirement.owner or '未填写'}" + ) + + +def _issue_snapshot(issue: ProjectIssue) -> dict[str, Any]: + """生成问题证据快照。""" + return { + "id": issue.id, + "title": issue.title, + "status": issue.status, + "severity": issue.severity, + "assignee": issue.assignee, + "description": issue.description, + "resolution": issue.resolution, + "updated_at": issue.updated_at, + } + + +def _issue_excerpt(issue: ProjectIssue) -> str: + """生成问题证据摘录。""" + return ( + f"问题:{issue.title};状态:{issue.status};严重程度:{issue.severity};" + f"指派人:{issue.assignee or '未填写'};解决方案:{issue.resolution or '未填写'}" + ) + + +def _knowledge_snapshot(entry: ProjectKnowledgeEntry) -> dict[str, Any]: + """生成知识条目证据快照。""" + return { + "id": entry.id, + "title": entry.title, + "source_type": entry.source_type, + "occurred_at": entry.occurred_at, + "content_hash": entry.content_hash, + "updated_at": entry.updated_at, + } + + +def _knowledge_candidate_statement( + entry: ProjectKnowledgeEntry, + *, + is_late_arrival: bool, +) -> str: + """生成需要人工提炼的知识候选事实。""" + excerpt = " ".join(entry.content.strip().split())[:240] + if is_late_arrival: + return ( + f"晚到补录资料“{entry.title}”的业务发生时间属于本期," + f"需人工核验后提炼可写事实:{excerpt}" + ) + return f"资料“{entry.title}”包含本期信息,需人工确认可写事实:{excerpt}" + + +def _critical_path_snapshot( + item: dict[str, Any], + plan: ProjectPlan, +) -> dict[str, Any]: + """生成关键路径证据快照。""" + keys = ( + "plan_id", + "task_code", + "duration_days", + "total_float", + "is_critical", + "predecessor_plan_ids", + "successor_plan_ids", + ) + return { + "path": {key: item.get(key) for key in keys}, + "plan": _plan_snapshot(plan), + } + + +def _snapshot_hash(payload: dict[str, Any]) -> str: + """计算稳定的来源快照哈希。""" + normalized = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def _date_in_period(value: date | None, start: date, end: date) -> bool: + """判断日期是否落在闭区间内。""" + return value is not None and start <= value <= end + + +def _ranges_overlap( + value_start: date | None, + value_end: date | None, + period_start: date, + period_end: date, +) -> bool: + """判断计划日期范围是否与周报周期相交。""" + if value_start is None and value_end is None: + return False + effective_start = value_start or value_end + effective_end = value_end or value_start + return bool(effective_start and effective_end and effective_start <= period_end and effective_end >= period_start) + + +def _has_completion_evidence(fact: ProjectWeeklyReportFact) -> bool: + """判断已完成事实是否具有明确完成证据。""" + current_value = _load_json_object(fact.current_value_json) or {} + if current_value.get("actual_end") or current_value.get("actual_date"): + return True + if int(current_value.get("progress_pct") or 0) >= 100: + return True + if ( + current_value.get("status") in {"已解决", "已关闭"} + and bool(str(current_value.get("resolution") or "").strip()) + ): + return True + return any( + ( + evidence.source_type == "completion_evidence" + or "完成证据" in (evidence.source_title or "") + or ( + bool((evidence.source_uri or "").strip()) + and (evidence.source_excerpt or "").strip() != fact.statement.strip() + ) + ) + and bool((evidence.source_excerpt or evidence.source_uri or "").strip()) + for evidence in fact.evidence + ) + + +def _evidence_stale_reason( + db: Session, + evidence: ProjectWeeklyReportEvidence, + cache: dict[str, str | None], + project_id: int, +) -> str | None: + """检查结构化证据在审核期间是否发生变化。""" + if evidence.source_type in {"manual", "external"} or not evidence.source_id: + return None + if not evidence.source_content_hash: + return "结构化来源缺少内容哈希,需要重新采集" + cache_key = f"{evidence.source_type}:{evidence.source_id}" + if cache_key not in cache: + cache[cache_key] = _current_source_hash( + db, + evidence.source_type, + evidence.source_id, + project_id, + ) + current_hash = cache[cache_key] + if current_hash is None: + return "引用的来源记录已经不存在" + if evidence.source_content_hash and current_hash != evidence.source_content_hash: + return "引用的来源记录已更新,需要重新采集并核验" + return None + + +def _current_source_hash( + db: Session, + source_type: str, + source_id: str, + project_id: int, +) -> str | None: + """读取结构化来源当前哈希。""" + try: + numeric_id = int(source_id) + except ValueError: + return None + if source_type == "plan": + item = ( + db.query(ProjectPlan) + .filter(ProjectPlan.id == numeric_id, ProjectPlan.project_id == project_id) + .first() + ) + return _snapshot_hash(_plan_snapshot(item)) if item else None + if source_type == "milestone": + item = ( + db.query(ProjectMilestone) + .filter( + ProjectMilestone.id == numeric_id, + ProjectMilestone.project_id == project_id, + ) + .first() + ) + return _snapshot_hash(_milestone_snapshot(item)) if item else None + if source_type == "requirement": + item = ( + db.query(ProjectRequirement) + .filter( + ProjectRequirement.id == numeric_id, + ProjectRequirement.project_id == project_id, + ) + .first() + ) + return _snapshot_hash(_requirement_snapshot(item)) if item else None + if source_type == "issue": + item = ( + db.query(ProjectIssue) + .filter(ProjectIssue.id == numeric_id, ProjectIssue.project_id == project_id) + .first() + ) + return _snapshot_hash(_issue_snapshot(item)) if item else None + if source_type == "knowledge": + item = ( + db.query(ProjectKnowledgeEntry) + .filter( + ProjectKnowledgeEntry.id == numeric_id, + ProjectKnowledgeEntry.project_id == project_id, + ) + .first() + ) + return _snapshot_hash(_knowledge_snapshot(item)) if item else None + if source_type == "critical_path": + plan = ( + db.query(ProjectPlan) + .filter(ProjectPlan.id == numeric_id, ProjectPlan.project_id == project_id) + .first() + ) + if plan is None: + return None + result = project_service.calculate_critical_path(db, plan.project_id) + item = next( + ( + value + for value in result.get("items", []) + if value.get("plan_id") == plan.id and value.get("is_critical") + ), + None, + ) + return _snapshot_hash(_critical_path_snapshot(item, plan)) if item else None + return None + + +def _fact_for_model(fact: ProjectWeeklyReportFact) -> dict[str, Any]: + """构造限制字段后的模型事实输入。""" + return { + "fact_id": fact.id, + "category": fact.category, + "module": fact.module, + "statement": fact.statement, + "business_status": fact.business_status, + "owner": fact.owner, + "action_due_at": fact.action_due_at, + "next_action": fact.next_action, + "impact_type": fact.impact_type, + "impact_description": fact.impact_description, + "decision_required": fact.decision_required, + "decision_owner": fact.decision_owner, + "decision_due_at": fact.decision_due_at, + "date_type": fact.date_type, + "date_value": fact.date_value, + "evidence": [ + { + "source_type": item.source_type, + "source_title": item.source_title, + "source_excerpt": (item.source_excerpt or "")[:600], + } + for item in fact.evidence[:3] + ], + } + + +def _build_generation_messages( + report: ProjectWeeklyReport, + facts: list[dict[str, Any]], +) -> list[dict[str, str]]: + """构造固定版本的周报生成任务卡。""" + system_prompt = """ +你是 TinyPM 的周报草稿整理助手。你只能改写输入中的已核验事实,不得补充日期、负责人、风险等级、结论或承诺。 +每个输出条目必须引用至少一个输入 fact_id。计划、预测、承诺、实际四种日期语义不得互换。 +禁止使用“持续推进”“基本正常”“问题不大”等没有数据支持的表达。 +只输出一个 JSON 对象,不要输出 Markdown 代码围栏或解释文字。 +JSON 结构: +{ + "title": "周报标题", + "summary": "总体概述,不替项目经理判断红黄绿状态", + "summary_fact_ids": [1], + "sections": [ + {"heading": "本周重点", "items": [{"text": "事实化表述", "fact_ids": [1]}]}, + {"heading": "计划进展", "items": []}, + {"heading": "阻塞和风险", "items": []}, + {"heading": "需要决策", "items": []}, + {"heading": "下周动作", "items": []} + ] +} +""".strip() + user_prompt = json.dumps( + { + "report": { + "title": report.title, + "period_start": report.week_start, + "period_end": report.week_end, + "cutoff_at": report.cutoff_at, + }, + "verified_facts": facts, + }, + ensure_ascii=False, + default=str, + ) + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + +def _parse_generated_payload(raw_output: str) -> GeneratedWeeklyReportPayload: + """解析并校验模型结构化输出。""" + normalized = raw_output.strip() + if normalized.startswith("```"): + normalized = re.sub(r"^```(?:json)?\s*", "", normalized, flags=re.IGNORECASE) + normalized = re.sub(r"\s*```$", "", normalized) + try: + payload = json.loads(normalized) + return GeneratedWeeklyReportPayload.model_validate(payload) + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"大模型周报草稿未通过结构化校验:{str(exc)[:300]}", + ) from exc + + +def _validate_generated_fact_ids( + generated: GeneratedWeeklyReportPayload, + allowed_fact_ids: set[int], +) -> set[int]: + """确认模型只引用已核验事实。""" + referenced = set(generated.summary_fact_ids) + for section in generated.sections: + for item in section.items: + referenced.update(item.fact_ids) + unknown = referenced - allowed_fact_ids + if unknown: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"大模型草稿引用了未核验事实ID:{sorted(unknown)}", + ) + if not referenced: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="大模型草稿没有引用任何已核验事实", + ) + return referenced + + +def _validate_generated_structure(generated: GeneratedWeeklyReportPayload) -> None: + """限制模型只能使用预定义的周报章节标题。""" + headings = [section.heading.strip() for section in generated.sections] + unknown = set(headings) - set(WEEKLY_REPORT_SECTION_HEADINGS) + if unknown: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"大模型返回了未允许的周报章节:{sorted(unknown)}", + ) + if len(headings) != len(set(headings)): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="大模型返回了重复的周报章节", + ) + + +def _validate_generated_semantics( + generated: GeneratedWeeklyReportPayload, + facts_by_id: dict[int, ProjectWeeklyReportFact], +) -> None: + """阻止模型把预测日期升级为承诺或实际结论。""" + _validate_generated_text_semantics( + generated.summary, + generated.summary_fact_ids, + facts_by_id, + ) + for section in generated.sections: + for item in section.items: + _validate_generated_text_semantics( + item.text, + item.fact_ids, + facts_by_id, + ) + + +def _validate_generated_text_semantics( + text: str, + fact_ids: list[int], + facts_by_id: dict[int, ProjectWeeklyReportFact], +) -> None: + """校验模型单条文本的日期值和日期语义。""" + facts = [facts_by_id[fact_id] for fact_id in fact_ids] + allowed_dates = { + fact.date_value.isoformat() + for fact in facts + if fact.date_value is not None + } + written_dates = _extract_written_dates(text) + unexpected_dates = written_dates - allowed_dates + if unexpected_dates: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"大模型写入了来源未支持的日期:{sorted(unexpected_dates)}", + ) + if any(fact.date_type == "forecast" for fact in facts) and any( + phrase in text for phrase in ("已确定", "已承诺", "实际完成", "已完成于") + ): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="大模型把预测日期改写成了确定承诺,请重新生成", + ) + if any(fact.date_type in {"planned", "committed"} for fact in facts) and any( + phrase in text for phrase in ("实际完成", "已完成于") + ): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="大模型把计划或承诺日期改写成了实际完成结论,请重新生成", + ) + + +def _render_generated_markdown( + generated: GeneratedWeeklyReportPayload, + report_title: str, +) -> str: + """把结构化周报渲染为可编辑 Markdown。""" + lines = [f"# {report_title}", "", "## 总体概述", ""] + lines.append(f"{generated.summary}{_fact_reference_suffix(generated.summary_fact_ids)}") + for section in generated.sections: + lines.extend(["", f"## {section.heading}", ""]) + if not section.items: + lines.append("- 本期无已核验事项。") + continue + for item in section.items: + lines.append(f"- {item.text}{_fact_reference_suffix(item.fact_ids)}") + return "\n".join(lines).strip() + + +def _fact_reference_suffix(fact_ids: list[int]) -> str: + """生成周报事实引用标记。""" + if not fact_ids: + return "" + return " " + " ".join(f"【事实#{fact_id}】" for fact_id in sorted(set(fact_ids))) + + +def _extract_fact_ids(content: str) -> set[int]: + """提取 Markdown 正文中的事实引用。""" + return {int(value) for value in re.findall(r"【事实#(\d+)】", content)} + + +def _validate_approved_content( + content: str, + verified_facts: dict[int, ProjectWeeklyReportFact], + report_title: str, +) -> tuple[list[str], set[int]]: + """校验人工终稿中的引用、日期语义和禁用表达。""" + blockers: list[str] = [] + for phrase in VAGUE_PHRASES: + if phrase in content: + blockers.append(f"人工终稿包含禁用的空泛表达“{phrase}”") + cited_ids = _extract_fact_ids(content) + if not cited_ids: + blockers.append("人工终稿必须引用至少一条已核验事实") + unknown_ids = cited_ids - set(verified_facts) + if unknown_ids: + blockers.append(f"人工终稿引用了未核验事实ID:{sorted(unknown_ids)}") + + uncited_lines = _find_uncited_content_lines( + content, + {report_title, "总体概述", *WEEKLY_REPORT_SECTION_HEADINGS}, + ) + if uncited_lines: + blockers.append( + f"人工终稿第 {uncited_lines[:8]} 行包含未引用事实的正文" + ) + + for line in content.splitlines(): + line_fact_ids = _extract_fact_ids(line) + line_facts = [verified_facts[fact_id] for fact_id in line_fact_ids if fact_id in verified_facts] + if not line_facts: + continue + allowed_dates = { + fact.date_value.isoformat() + for fact in line_facts + if fact.date_value is not None + } + written_dates = _extract_written_dates(line) + unexpected_dates = written_dates - allowed_dates + if unexpected_dates: + blockers.append( + f"终稿引用事实 {sorted(line_fact_ids)} 时写入了来源未支持的日期:{sorted(unexpected_dates)}" + ) + if any(fact.date_type == "forecast" for fact in line_facts) and any( + phrase in line for phrase in ("已承诺", "已确定", "实际完成", "已完成于") + ): + blockers.append( + f"终稿把预测日期事实 {sorted(line_fact_ids)} 改写成了承诺或实际结论" + ) + if any(fact.date_type in {"planned", "committed"} for fact in line_facts) and any( + phrase in line for phrase in ("实际完成", "已完成于") + ): + blockers.append( + f"终稿把计划或承诺日期事实 {sorted(line_fact_ids)} 改写成了实际完成结论" + ) + return blockers, cited_ids + + +def _find_uncited_content_lines( + content: str, + allowed_headings: set[str] | None = None, +) -> list[int]: + """查找没有事实引用的 Markdown 正文行。""" + lines = content.splitlines() + missing: list[int] = [] + in_code_block = False + for index, raw_line in enumerate(lines, start=1): + line = raw_line.strip() + if line.startswith("```"): + missing.append(index) + in_code_block = not in_code_block + continue + if in_code_block or not line: + continue + if line.startswith("#"): + heading = line.lstrip("#").strip() + if heading in (allowed_headings or set()): + continue + if line in {"---", "***", "___"}: + continue + if line == "- 本期无已核验事项。": + continue + if _is_markdown_table_separator(line): + continue + if not _extract_fact_ids(line): + missing.append(index) + return missing + + +def _is_markdown_table_separator(line: str) -> bool: + """判断一行是否仅为 Markdown 表格分隔符。""" + if "|" not in line: + return False + cells = [cell.strip() for cell in line.strip("|").split("|")] + return bool(cells) and all( + re.fullmatch(r":?-{3,}:?", cell) is not None + for cell in cells + ) + + +def _extract_written_dates(text: str) -> set[str]: + """提取并规范化常见中英文日期写法。""" + normalized: set[str] = set() + for year, month, day in re.findall(r"\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b", text): + try: + normalized.add(date(int(year), int(month), int(day)).isoformat()) + except ValueError: + continue + for year, month, day in re.findall(r"(\d{4})年(\d{1,2})月(\d{1,2})日", text): + try: + normalized.add(date(int(year), int(month), int(day)).isoformat()) + except ValueError: + continue + return normalized + + +def _next_version_number(db: Session, report_id: int, audience_type: str) -> int: + """计算受众版本的下一个版本号。""" + current = ( + db.query(func.max(ProjectWeeklyReportVersion.version_no)) + .filter( + ProjectWeeklyReportVersion.report_id == report_id, + ProjectWeeklyReportVersion.audience_type == audience_type, + ) + .scalar() + ) + return int(current or 0) + 1 + + +def _lock_report_for_update( + db: Session, + report_id: int, +) -> ProjectWeeklyReport: + """锁定周报主记录,串行化事实、状态与版本修改。""" + report = ( + db.query(ProjectWeeklyReport) + .filter(ProjectWeeklyReport.id == report_id) + .populate_existing() + .with_for_update() + .first() + ) + if report is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"周报ID {report_id} 不存在", + ) + return report + + +def _load_json_object(value: str | None) -> dict[str, Any] | None: + """安全解析JSON对象。""" + if not value: + return None + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _load_json_list(value: str | None) -> list[int]: + """安全解析事实ID列表。""" + if not value: + return [] + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + result: list[int] = [] + for item in parsed: + try: + result.append(int(item)) + except (TypeError, ValueError): + continue + return result + + +def _load_json_string_map(value: str | None) -> dict[str, str]: + """安全解析字符串键值JSON对象。""" + if not value: + return {} + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + if not isinstance(parsed, dict): + return {} + return { + str(key): str(item) + for key, item in parsed.items() + if item is not None + } + + +def _dump_fact_hashes(facts: Iterable[ProjectWeeklyReportFact]) -> str: + """序列化一组事实的冻结快照哈希。""" + hashes = { + str(fact.id): _fact_snapshot_hash(fact) + for fact in facts + } + return json.dumps(hashes, ensure_ascii=False, sort_keys=True) + + +def _fact_snapshot_hash(fact: ProjectWeeklyReportFact) -> str: + """计算事实正文、审核信息和证据快照的稳定哈希。""" + snapshot = { + "id": fact.id, + "category": fact.category, + "module": fact.module, + "statement": fact.statement, + "business_status": fact.business_status, + "review_status": fact.review_status, + "audience_scope": fact.audience_scope, + "owner": fact.owner, + "action_due_at": fact.action_due_at, + "next_action": fact.next_action, + "impact_type": fact.impact_type, + "impact_description": fact.impact_description, + "decision_required": fact.decision_required, + "decision_owner": fact.decision_owner, + "decision_due_at": fact.decision_due_at, + "date_type": fact.date_type, + "date_value": fact.date_value, + "previous_value_json": fact.previous_value_json, + "current_value_json": fact.current_value_json, + "source_mode": fact.source_mode, + "verified_by": fact.verified_by, + "verified_at": fact.verified_at, + "evidence": [ + { + "source_type": evidence.source_type, + "source_id": evidence.source_id, + "source_uri": evidence.source_uri, + "source_title": evidence.source_title, + "source_excerpt": evidence.source_excerpt, + "source_updated_at": evidence.source_updated_at, + "source_content_hash": evidence.source_content_hash, + } + for evidence in fact.evidence + ], + } + return _snapshot_hash(snapshot) diff --git a/backend/app/services/wiki.py b/backend/app/services/wiki.py new file mode 100644 index 0000000..e8fe06c --- /dev/null +++ b/backend/app/services/wiki.py @@ -0,0 +1,1376 @@ +"""项目 LLM Wiki 入箱、增量编译和检索服务。""" + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import hashlib +import json +import logging +import re +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.config import settings +from app.database import SessionLocal +from app.models.agent import AgentMessage, AgentSession, AgentSetting +from app.models.project import Project, ProjectDocumentFile, ProjectKnowledgeEntry +from app.models.user import USER_ROLE_ADMIN, User +from app.models.wiki import ( + ProjectWikiCompilationRun, + ProjectWikiPage, + ProjectWikiPageSource, + ProjectWikiSource, +) +from app.schemas.wiki import ( + ProjectWikiOverviewResponse, + WikiCompilationPayload, + WikiCompilationRunResponse, + WikiPageResponse, + WikiPageSourceReference, + WikiSourcePageReference, + WikiSourceResponse, +) +from app.services import agent as agent_service +from app.services import wiki_extract, wiki_vault + + +logger = logging.getLogger(__name__) + +WIKI_PAGE_TYPES = {"topic", "concept", "synthesis", "idea"} +WIKI_RELATION_TYPES = {"source_claim", "ai_inference", "evidence"} +WIKI_TERMINAL_SOURCE_STATUSES = {"compiled", "unsupported", "superseded"} +WIKI_ACTIVE_RUN_STATUS = "running" +RAW_CONTENT_PREVIEW_LIMIT = 2400 +PAGE_CONTEXT_LIMIT = 8 +PAGE_INDEX_LIMIT = 200 +SYSTEM_WIKI_TARGETS = {"index", "current-status", "log", "ideas"} + + +@dataclass(frozen=True) +class WikiModelRuntimeConfig: + """一次 Wiki 编译使用的模型运行配置。""" + + api_base_url: str + api_key: str + model: str + + +@dataclass(frozen=True) +class WikiSourceCompileSnapshot: + """脱离 ORM 会话的一次来源编译只读快照。""" + + id: int + project_id: int + source_id: str + title: str + source_type: str + origin_kind: str + source_uri: str | None + trust_level: str + occurred_at: datetime | None + raw_content: str + + +def ingest_source( + db: Session, + project_id: int, + *, + title: str, + source_type: str, + origin_kind: str, + raw_content: str, + origin_ref: str | None = None, + source_uri: str | None = None, + occurred_at: datetime | None = None, + author_user_id: int | None = None, + trust_level: str = "unverified", + initial_status: str = "inbox", + error_message: str | None = None, + include_occurred_at_in_hash: bool = True, +) -> tuple[ProjectWikiSource, bool]: + """幂等创建不可变来源快照,并淘汰同业务记录的未编译旧版本。""" + _get_project(db, project_id) + normalized_title = title.strip() or "未命名来源" + normalized_content = raw_content.replace("\x00", "") + normalized_origin_ref = _clean_optional_text(origin_ref) + normalized_source_uri = _clean_optional_text(source_uri) + content_hash = hashlib.sha256(normalized_content.encode("utf-8")).hexdigest() + snapshot_payload = { + "title": normalized_title, + "source_type": source_type, + "raw_content": normalized_content, + "occurred_at": ( + occurred_at.isoformat() + if include_occurred_at_in_hash and occurred_at is not None + else None + ), + } + snapshot_hash = hashlib.sha256( + json.dumps( + snapshot_payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + existing = ( + db.query(ProjectWikiSource) + .filter( + ProjectWikiSource.project_id == project_id, + ProjectWikiSource.snapshot_hash == snapshot_hash, + ) + .first() + ) + if existing is not None: + return existing, False + + source_id = _build_unique_source_id(db, project_id, snapshot_hash) + if normalized_origin_ref: + outdated_sources = ( + db.query(ProjectWikiSource) + .filter( + ProjectWikiSource.project_id == project_id, + ProjectWikiSource.origin_kind == origin_kind, + ProjectWikiSource.origin_ref == normalized_origin_ref, + ProjectWikiSource.status.in_(["inbox", "failed", "compiling"]), + ) + .all() + ) + for outdated_source in outdated_sources: + outdated_source.status = "superseded" + outdated_source.error_message = "同一业务来源已有更新快照" + + source = ProjectWikiSource( + project_id=project_id, + author_user_id=author_user_id, + source_id=source_id, + origin_kind=origin_kind, + origin_ref=normalized_origin_ref, + title=normalized_title, + source_type=source_type, + source_uri=normalized_source_uri, + raw_content=normalized_content, + content_hash=content_hash, + snapshot_hash=snapshot_hash, + trust_level=trust_level, + status=initial_status, + error_message=error_message, + occurred_at=occurred_at, + ) + db.add(source) + db.flush() + return source, True + + +def ingest_knowledge_entry( + db: Session, + entry: ProjectKnowledgeEntry, + *, + author_user_id: int | None = None, +) -> tuple[ProjectWikiSource, bool]: + """把项目知识条目的当前版本加入 Wiki 来源收件箱。""" + if entry.id is None: + db.flush() + return ingest_source( + db, + entry.project_id, + title=entry.title, + source_type=entry.source_type, + origin_kind="knowledge_entry", + origin_ref=f"knowledge-entry:{entry.id}", + raw_content=entry.content, + source_uri=entry.source_uri, + occurred_at=entry.occurred_at, + author_user_id=author_user_id, + trust_level="managed", + ) + + +def ingest_project_document( + db: Session, + document: ProjectDocumentFile, + *, + author_user_id: int | None = None, +) -> tuple[ProjectWikiSource, bool]: + """提取项目文档正文并加入 Wiki 来源收件箱。""" + if document.id is None: + db.flush() + extraction = wiki_extract.extract_project_document(document) + source_uri = f"/api/documents-storage/{document.file_path.lstrip('/')}" + return ingest_source( + db, + document.project_id, + title=document.original_name, + source_type="document", + origin_kind="document", + origin_ref=f"document:{document.id}", + raw_content=extraction.content, + source_uri=source_uri, + occurred_at=document.created_at or datetime.now(timezone.utc), + author_user_id=author_user_id, + trust_level="managed", + initial_status=extraction.status, + error_message=extraction.error_message, + include_occurred_at_in_hash=False, + ) + + +def ingest_chat_round( + db: Session, + session: AgentSession, + user_message: AgentMessage, + assistant_message: AgentMessage, + *, + author: User, + tool_summaries: list[Any], +) -> tuple[ProjectWikiSource | None, bool]: + """把绑定项目的一轮成功聊天保存为不可变来源快照。""" + if session.project_id is None or assistant_message.is_error: + return None, False + if user_message.id is None or assistant_message.id is None: + db.flush() + + tools = [] + for item in tool_summaries: + if hasattr(item, "model_dump"): + tools.append(item.model_dump()) + elif isinstance(item, dict): + tools.append(item) + else: + tools.append({"summary": str(item)}) + raw_content = "\n".join( + [ + f"# {session.title}", + "", + f"- session_id: {session.id}", + f"- user_message_id: {user_message.id}", + f"- assistant_message_id: {assistant_message.id}", + f"- project_id_at_time: {session.project_id}", + f"- author_user_id: {author.id}", + f"- skill_id: {session.skill_id}", + f"- model: {assistant_message.model or 'unknown'}", + "", + "## 用户陈述", + "", + user_message.content, + "", + "## 助手回答", + "", + "> 可信类别:AI 生成内容,除非存在可追溯项目证据,否则不得视为已验证事实。", + "", + assistant_message.content, + "", + "## 工具调用摘要", + "", + "```json", + json.dumps(tools, ensure_ascii=False, indent=2, default=str), + "```", + ] + ) + occurred_at = assistant_message.created_at or datetime.now(timezone.utc) + trust_level = "managed" if author.role in {USER_ROLE_ADMIN, "manager"} else "unverified" + return ingest_source( + db, + session.project_id, + title=f"会话:{session.title} · 消息 {assistant_message.id}", + source_type="chat_record", + origin_kind="agent_chat", + origin_ref=f"agent-session:{session.id}:assistant-message:{assistant_message.id}", + raw_content=raw_content, + source_uri=f"tinypm://agent/sessions/{session.id}#message-{assistant_message.id}", + occurred_at=occurred_at, + author_user_id=author.id, + trust_level=trust_level, + ) + + +def backfill_project_sources( + db: Session, + project_id: int, + *, + author_user_id: int | None = None, +) -> dict[str, int]: + """幂等补录项目已有知识条目和文档,不猜测历史聊天的项目归属。""" + _get_project(db, project_id) + created_count = 0 + existing_count = 0 + for entry in ( + db.query(ProjectKnowledgeEntry) + .filter(ProjectKnowledgeEntry.project_id == project_id) + .order_by(ProjectKnowledgeEntry.id.asc()) + .all() + ): + _, created = ingest_knowledge_entry( + db, + entry, + author_user_id=author_user_id, + ) + created_count += int(created) + existing_count += int(not created) + for document in ( + db.query(ProjectDocumentFile) + .filter(ProjectDocumentFile.project_id == project_id) + .order_by(ProjectDocumentFile.id.asc()) + .all() + ): + _, created = ingest_project_document( + db, + document, + author_user_id=author_user_id, + ) + created_count += int(created) + existing_count += int(not created) + db.commit() + return {"created_count": created_count, "existing_count": existing_count} + + +def get_wiki_overview(db: Session, project_id: int) -> ProjectWikiOverviewResponse: + """返回项目 Wiki 的状态、来源、页面和最近编译批次。""" + _get_project(db, project_id) + sources = list_wiki_sources(db, project_id, limit=100) + pages = list_wiki_pages(db, project_id, limit=100) + runs = list_compilation_runs(db, project_id, limit=20) + source_counts = { + str(row[0]): int(row[1]) + for row in ( + db.query(ProjectWikiSource.status, func.count(ProjectWikiSource.id)) + .filter(ProjectWikiSource.project_id == project_id) + .group_by(ProjectWikiSource.status) + .all() + ) + } + page_counts = { + str(row[0]): int(row[1]) + for row in ( + db.query(ProjectWikiPage.page_type, func.count(ProjectWikiPage.id)) + .filter(ProjectWikiPage.project_id == project_id) + .group_by(ProjectWikiPage.page_type) + .all() + ) + } + return ProjectWikiOverviewResponse( + project_id=project_id, + source_counts=source_counts, + page_counts=page_counts, + sources=[serialize_wiki_source(source) for source in sources], + pages=[serialize_wiki_page(page) for page in pages], + recent_runs=[serialize_compilation_run(run) for run in runs], + ) + + +def list_wiki_sources( + db: Session, + project_id: int, + *, + query: str | None = None, + source_status: str | None = None, + source_type: str | None = None, + limit: int = 100, +) -> list[ProjectWikiSource]: + """按状态、类型和关键词检索 Wiki 来源快照。""" + _get_project(db, project_id) + source_query = ( + db.query(ProjectWikiSource) + .filter(ProjectWikiSource.project_id == project_id) + .options( + selectinload(ProjectWikiSource.page_links).selectinload( + ProjectWikiPageSource.page + ) + ) + ) + if source_status: + source_query = source_query.filter(ProjectWikiSource.status == source_status) + if source_type: + source_query = source_query.filter(ProjectWikiSource.source_type == source_type) + if query and query.strip(): + keyword = f"%{query.strip()}%" + source_query = source_query.filter( + or_( + ProjectWikiSource.title.ilike(keyword), + ProjectWikiSource.raw_content.ilike(keyword), + ProjectWikiSource.compiled_markdown.ilike(keyword), + ) + ) + return ( + source_query.order_by( + ProjectWikiSource.imported_at.desc(), + ProjectWikiSource.id.desc(), + ) + .limit(max(1, min(limit, 200))) + .all() + ) + + +def list_wiki_pages( + db: Session, + project_id: int, + *, + query: str | None = None, + page_type: str | None = None, + limit: int = 100, +) -> list[ProjectWikiPage]: + """按页面类型和关键词检索长期 Wiki 页面。""" + _get_project(db, project_id) + page_query = ( + db.query(ProjectWikiPage) + .filter(ProjectWikiPage.project_id == project_id) + .options( + selectinload(ProjectWikiPage.source_links).selectinload( + ProjectWikiPageSource.source + ) + ) + ) + if page_type: + page_query = page_query.filter(ProjectWikiPage.page_type == page_type) + if query and query.strip(): + keyword = f"%{query.strip()}%" + page_query = page_query.filter( + or_( + ProjectWikiPage.title.ilike(keyword), + ProjectWikiPage.summary.ilike(keyword), + ProjectWikiPage.content_markdown.ilike(keyword), + ) + ) + return ( + page_query.order_by(ProjectWikiPage.updated_at.desc(), ProjectWikiPage.id.desc()) + .limit(max(1, min(limit, 200))) + .all() + ) + + +def get_wiki_page(db: Session, project_id: int, slug: str) -> ProjectWikiPage: + """按项目和 slug 获取单个 Wiki 页面。""" + page = ( + db.query(ProjectWikiPage) + .filter( + ProjectWikiPage.project_id == project_id, + ProjectWikiPage.slug == slug, + ) + .options( + selectinload(ProjectWikiPage.source_links).selectinload( + ProjectWikiPageSource.source + ) + ) + .first() + ) + if page is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wiki页面不存在") + return page + + +def list_compilation_runs( + db: Session, + project_id: int, + *, + limit: int = 20, +) -> list[ProjectWikiCompilationRun]: + """返回项目最近的 Wiki 编译批次。""" + _get_project(db, project_id) + return ( + db.query(ProjectWikiCompilationRun) + .filter(ProjectWikiCompilationRun.project_id == project_id) + .order_by(ProjectWikiCompilationRun.started_at.desc()) + .limit(max(1, min(limit, 100))) + .all() + ) + + +def get_compilation_run( + db: Session, + project_id: int, + run_id: int, +) -> ProjectWikiCompilationRun: + """获取指定项目的单个 Wiki 编译批次。""" + run = ( + db.query(ProjectWikiCompilationRun) + .filter( + ProjectWikiCompilationRun.project_id == project_id, + ProjectWikiCompilationRun.id == run_id, + ) + .first() + ) + if run is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wiki编译批次不存在") + return run + + +def serialize_wiki_source(source: ProjectWikiSource) -> WikiSourceResponse: + """序列化 Wiki 来源及其页面引用。""" + preview = source.raw_content[:RAW_CONTENT_PREVIEW_LIMIT] + return WikiSourceResponse( + id=source.id, + project_id=source.project_id, + author_user_id=source.author_user_id, + source_id=source.source_id, + origin_kind=source.origin_kind, + origin_ref=source.origin_ref, + title=source.title, + source_type=source.source_type, + source_uri=source.source_uri, + trust_level=source.trust_level, + status=source.status, + compiled_markdown=source.compiled_markdown, + error_message=source.error_message, + occurred_at=source.occurred_at, + imported_at=source.imported_at, + compiled_at=source.compiled_at, + created_at=source.created_at, + updated_at=source.updated_at, + content_hash=source.content_hash, + snapshot_hash=source.snapshot_hash, + raw_content_preview=preview, + raw_content_truncated=len(source.raw_content) > RAW_CONTENT_PREVIEW_LIMIT, + pages=[ + WikiSourcePageReference( + id=link.page.id, + slug=link.page.slug, + title=link.page.title, + page_type=link.page.page_type, + relation_type=link.relation_type, + ) + for link in source.page_links + if link.page is not None + ], + ) + + +def serialize_wiki_page(page: ProjectWikiPage) -> WikiPageResponse: + """序列化 Wiki 页面及其来源引用。""" + return WikiPageResponse( + id=page.id, + project_id=page.project_id, + slug=page.slug, + page_type=page.page_type, + title=page.title, + summary=page.summary, + content_markdown=page.content_markdown, + status=page.status, + revision=page.revision, + created_at=page.created_at, + updated_at=page.updated_at, + sources=[ + WikiPageSourceReference( + source_id=link.source.source_id, + title=link.source.title, + relation_type=link.relation_type, + trust_level=link.source.trust_level, + ) + for link in page.source_links + if link.source is not None + ], + ) + + +def serialize_compilation_run( + run: ProjectWikiCompilationRun, +) -> WikiCompilationRunResponse: + """序列化 Wiki 编译批次。""" + return WikiCompilationRunResponse( + id=run.id, + project_id=run.project_id, + requested_by_user_id=run.requested_by_user_id, + trigger=run.trigger, + status=run.status, + model=run.model, + source_count=run.source_count, + page_count=run.page_count, + lint_errors=_load_string_list(run.lint_errors_json), + error_message=run.error_message, + started_at=run.started_at, + finished_at=run.finished_at, + ) + + +async def compile_project_inbox( + db: Session, + project_id: int, + user: User, + *, + trigger: str, + source_ids: list[str] | None = None, + include_failed: bool = False, +) -> ProjectWikiCompilationRun: + """领取项目待处理来源,调用 LLM 增量维护 Wiki 页面。""" + setting = agent_service.get_or_create_setting(db, user) + normalized_source_ids = [item.strip() for item in source_ids or [] if item.strip()] + run, sources = _claim_compilation_run( + db, + project_id, + user, + setting, + trigger=trigger, + source_ids=normalized_source_ids or None, + include_failed=include_failed, + ) + if run.status != WIKI_ACTIVE_RUN_STATUS or not sources: + return run + + runtime_config = WikiModelRuntimeConfig( + api_base_url=setting.api_base_url, + api_key=setting.api_key or "", + model=setting.model, + ) + db.commit() + page_count = 0 + failures: list[str] = [] + for source in sources: + try: + page_count += await _compile_single_source( + db, + source.id, + runtime_config, + ) + except Exception as exc: + error_message = _format_compilation_error(exc) + logger.exception( + "Wiki来源编译失败,project_id=%s,source_id=%s", + project_id, + source.source_id, + ) + _mark_source_failed(db, source.id, error_message) + failures.append(f"{source.source_id}: {error_message}") + + lint_errors = wiki_vault.lint_project_wiki(db, project_id) + run = get_compilation_run(db, project_id, run.id) + run.page_count = page_count + run.lint_errors_json = json.dumps(lint_errors, ensure_ascii=False) + run.finished_at = datetime.now(timezone.utc) + if failures: + run.status = "completed_with_errors" + run.error_message = ";".join(failures)[:4000] + elif lint_errors: + run.status = "completed_with_warnings" + run.error_message = "Wiki完整性检查存在警告" + else: + run.status = "completed" + run.error_message = None + db.commit() + db.refresh(run) + + try: + wiki_vault.write_project_vault(db, project_id) + except OSError as exc: + logger.exception("生成 Obsidian 投影失败,project_id=%s", project_id) + run.status = "completed_with_warnings" + run.error_message = ( + f"{run.error_message + ';' if run.error_message else ''}" + f"Obsidian投影失败:{str(exc)[:300]}" + ) + db.commit() + db.refresh(run) + return run + + +async def compile_project_background( + project_id: int, + user_id: int, + trigger: str, +) -> None: + """使用独立数据库会话在 HTTP 响应后唤醒 Wiki 编译。""" + if not settings.WIKI_AUTO_COMPILE_ENABLED: + return + db = SessionLocal() + try: + user = ( + db.query(User) + .filter(User.id == user_id, User.is_active.is_(True)) + .first() + ) + if user is None: + logger.warning("Wiki后台编译找不到启用用户,user_id=%s", user_id) + return + await compile_project_inbox( + db, + project_id, + user, + trigger=trigger, + ) + except Exception: + logger.exception( + "Wiki后台编译唤醒失败,project_id=%s,user_id=%s", + project_id, + user_id, + ) + db.rollback() + finally: + db.close() + + +def find_auto_compile_candidate(db: Session) -> tuple[int, int] | None: + """查找一个具备已保存模型凭据的待自动编译项目和用户。""" + if not settings.WIKI_AUTO_COMPILE_ENABLED: + return None + active_projects = select(ProjectWikiCompilationRun.project_id).where( + ProjectWikiCompilationRun.status == WIKI_ACTIVE_RUN_STATUS + ) + candidate = ( + db.query(ProjectWikiSource.project_id, ProjectWikiSource.author_user_id) + .join(User, User.id == ProjectWikiSource.author_user_id) + .join(AgentSetting, AgentSetting.user_id == User.id) + .filter( + ProjectWikiSource.status == "inbox", + ProjectWikiSource.author_user_id.is_not(None), + User.is_active.is_(True), + AgentSetting.api_key.is_not(None), + func.length(func.trim(AgentSetting.api_key)) > 0, + ~ProjectWikiSource.project_id.in_(active_projects), + ) + .order_by(ProjectWikiSource.imported_at.asc(), ProjectWikiSource.id.asc()) + .first() + ) + if candidate is None or candidate[1] is None: + return None + return int(candidate[0]), int(candidate[1]) + + +def recover_stale_compilations(db: Session) -> int: + """恢复因进程退出而长时间停留在运行态的编译批次和来源。""" + stale_minutes = max(5, settings.WIKI_COMPILATION_STALE_MINUTES) + cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes) + stale_runs = ( + db.query(ProjectWikiCompilationRun) + .filter( + ProjectWikiCompilationRun.status == WIKI_ACTIVE_RUN_STATUS, + ProjectWikiCompilationRun.started_at < cutoff, + ) + .all() + ) + if not stale_runs: + return 0 + now = datetime.now(timezone.utc) + for run in stale_runs: + run.status = "failed" + run.error_message = "编译进程中断,来源已恢复为待处理状态" + run.finished_at = now + sources = ( + db.query(ProjectWikiSource) + .filter( + ProjectWikiSource.project_id == run.project_id, + ProjectWikiSource.status == "compiling", + ) + .all() + ) + for source in sources: + source.status = "inbox" + source.error_message = "上一次编译进程中断,等待重新领取" + db.commit() + return len(stale_runs) + + +def reset_failed_sources( + db: Session, + project_id: int, + *, + source_ids: list[str] | None = None, +) -> int: + """把指定项目的失败来源恢复为待编译状态。""" + _get_project(db, project_id) + query = db.query(ProjectWikiSource).filter( + ProjectWikiSource.project_id == project_id, + ProjectWikiSource.status == "failed", + ) + if source_ids: + query = query.filter(ProjectWikiSource.source_id.in_(source_ids)) + sources = query.all() + for source in sources: + source.status = "inbox" + source.error_message = None + db.commit() + return len(sources) + + +def build_project_vault_zip(db: Session, project_id: int) -> bytes: + """检查项目存在后生成 Obsidian 兼容 ZIP。""" + _get_project(db, project_id) + return wiki_vault.build_project_vault_zip(db, project_id) + + +def _claim_compilation_run( + db: Session, + project_id: int, + user: User, + setting: AgentSetting, + *, + trigger: str, + source_ids: list[str] | None, + include_failed: bool, +) -> tuple[ProjectWikiCompilationRun, list[ProjectWikiSource]]: + """在项目行锁下串行领取来源并创建持久编译批次。""" + project = ( + db.query(Project) + .filter(Project.id == project_id) + .with_for_update() + .first() + ) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + active_run = ( + db.query(ProjectWikiCompilationRun) + .filter( + ProjectWikiCompilationRun.project_id == project_id, + ProjectWikiCompilationRun.status == WIKI_ACTIVE_RUN_STATUS, + ) + .order_by(ProjectWikiCompilationRun.started_at.desc()) + .first() + ) + if active_run is not None: + db.commit() + return active_run, [] + + accepted_statuses = ["inbox"] + if include_failed: + accepted_statuses.append("failed") + source_query = ( + db.query(ProjectWikiSource) + .filter( + ProjectWikiSource.project_id == project_id, + ProjectWikiSource.status.in_(accepted_statuses), + ) + .order_by(ProjectWikiSource.imported_at.asc(), ProjectWikiSource.id.asc()) + ) + if source_ids: + source_query = source_query.filter(ProjectWikiSource.source_id.in_(source_ids)) + sources = ( + source_query.limit(max(1, settings.WIKI_MAX_SOURCES_PER_RUN)) + .with_for_update() + .all() + ) + + run = ProjectWikiCompilationRun( + project_id=project_id, + requested_by_user_id=user.id, + trigger=trigger, + status=WIKI_ACTIVE_RUN_STATUS, + model=setting.model, + source_count=len(sources), + page_count=0, + lint_errors_json="[]", + ) + db.add(run) + db.flush() + if not sources: + run.status = "completed" + run.finished_at = datetime.now(timezone.utc) + db.commit() + db.refresh(run) + return run, [] + if not setting.api_key or not setting.api_key.strip(): + run.status = "failed" + run.error_message = "当前用户未保存智能助理 API Key,来源仍保留在收件箱" + run.finished_at = datetime.now(timezone.utc) + db.commit() + db.refresh(run) + return run, [] + + for source in sources: + source.status = "compiling" + source.error_message = None + db.commit() + db.refresh(run) + return run, sources + + +async def _compile_single_source( + db: Session, + source_database_id: int, + runtime_config: WikiModelRuntimeConfig, +) -> int: + """完整编译单个来源,并用短事务写入页面和追溯关系。""" + source = ( + db.query(ProjectWikiSource) + .filter(ProjectWikiSource.id == source_database_id) + .first() + ) + if source is None or source.status != "compiling": + return 0 + + source_snapshot = WikiSourceCompileSnapshot( + id=source.id, + project_id=source.project_id, + source_id=source.source_id, + title=source.title, + source_type=source.source_type, + origin_kind=source.origin_kind, + source_uri=source.source_uri, + trust_level=source.trust_level, + occurred_at=source.occurred_at, + raw_content=source.raw_content, + ) + chunks, was_truncated = _split_source_content(source_snapshot.raw_content) + page_index, editable_pages, editable_slugs = _build_page_context( + db, + source_snapshot.project_id, + source_snapshot.raw_content, + ) + db.commit() + if len(chunks) == 1: + compile_content = chunks[0] + else: + digests: list[str] = [] + for index, chunk in enumerate(chunks, start=1): + digest_payload = await _request_chunk_digest( + runtime_config, + source_snapshot, + chunk, + index=index, + total=len(chunks), + ) + digests.append( + f"## 来源分段 {index}/{len(chunks)}\n\n" + f"{digest_payload.source_note_markdown[:6000]}" + ) + compile_content = "\n\n".join(digests) + if was_truncated: + compile_content += "\n\n> 来源超过首版最大分段数,未覆盖的原文必须列为待验证缺口。" + + payload = await _request_final_compilation( + runtime_config, + source_snapshot, + compile_content, + page_index=page_index, + editable_pages=editable_pages, + input_is_digest=len(chunks) > 1, + ) + return _apply_compilation_payload( + db, + source_database_id, + payload, + editable_slugs=editable_slugs, + ) + + +async def _request_chunk_digest( + runtime_config: WikiModelRuntimeConfig, + source: WikiSourceCompileSnapshot, + chunk: str, + *, + index: int, + total: int, +) -> WikiCompilationPayload: + """把长来源的单个分段压缩为带证据分类的编译说明。""" + system_prompt = """ +你是 TinyPM LLM Wiki 的来源分析器。你只分析给定原文分段,不维护 Wiki 页面。 +返回且只返回合法 JSON,不要使用 Markdown 代码围栏。结构必须是: +{ + "should_compile": true或false, + "source_note_markdown": "Markdown,区分原文观点、AI推断、待验证问题", + "pages": [] +} +要求: +1. 原文明确表达的内容标为“来源观点”,不等于已验证事实。 +2. 你的补充判断必须标为“AI推断”。 +3. 数字、承诺、法规、结论性判断和证据不足内容列入“待验证”。 +4. 问候、致谢或无长期价值内容可以 should_compile=false,但仍说明原因。 +5. pages 必须为空数组。 +""".strip() + user_payload = { + "source": _source_prompt_metadata(source), + "chunk": {"index": index, "total": total, "content": chunk}, + } + raw_output, _ = await agent_service.request_plain_completion( + api_base_url=runtime_config.api_base_url, + api_key=runtime_config.api_key, + model=runtime_config.model, + messages=[ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": json.dumps(user_payload, ensure_ascii=False, default=str), + }, + ], + ) + payload = _parse_compilation_payload(raw_output) + if payload.pages: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="来源分段分析不允许返回 Wiki 页面", + ) + return payload + + +async def _request_final_compilation( + runtime_config: WikiModelRuntimeConfig, + source: WikiSourceCompileSnapshot, + source_content: str, + *, + page_index: list[dict[str, Any]], + editable_pages: list[dict[str, Any]], + input_is_digest: bool, +) -> WikiCompilationPayload: + """请求模型根据来源和已有页面生成一次增量 Wiki 变更。""" + system_prompt = """ +你是 TinyPM 项目的 LLM Wiki 编译器。你的任务是把新来源增量整合进持久 Wiki,而不是生成孤立摘要。 +返回且只返回合法 JSON,不要使用 Markdown 代码围栏。结构必须是: +{ + "should_compile": true或false, + "source_note_markdown": "来源稳定要点、AI推断和待验证问题的Markdown", + "pages": [ + { + "slug": "稳定页面标识", + "page_type": "topic|concept|synthesis|idea", + "title": "页面标题", + "summary": "用于检索的简短摘要", + "content_markdown": "完整页面Markdown", + "relation_type": "source_claim|ai_inference|evidence" + } + ] +} +编译规则: +1. 优先更新已有页面,避免创建近义 topic 或 concept。 +2. 只有 editable_pages 提供了完整正文的旧页面才允许更新;其余页面只能用于判断是否已存在,不得覆盖。 +3. 页面必须是完整正文,并明确分开“来源观点”“AI推断”“待验证问题”“相关来源”。 +4. 来源观点不等于已验证事实;trust_level=unverified 时尤其不得写成确定事实。 +5. idea 必须包含依据、可能反证和下一步验证;没有可行动假设就不要创建 idea。 +6. 每个页面必须通过 [[source_id]] 引用本次来源,可使用已存在页面 slug 建立双向链接。 +7. 不要生成 index、current-status、log 或 source 页面,这些由 TinyPM 自动维护。 +8. 没有长期价值时 should_compile=false 且 pages=[],但仍填写 source_note_markdown。 +9. should_compile=false 时严禁返回 pages。 +""".strip() + user_payload = { + "source": _source_prompt_metadata(source), + "input_kind": "分段编译说明" if input_is_digest else "原文", + "source_content": source_content, + "existing_page_index": page_index, + "editable_pages": editable_pages, + } + raw_output, _ = await agent_service.request_plain_completion( + api_base_url=runtime_config.api_base_url, + api_key=runtime_config.api_key, + model=runtime_config.model, + messages=[ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": json.dumps(user_payload, ensure_ascii=False, default=str), + }, + ], + ) + payload = _parse_compilation_payload(raw_output) + if not payload.should_compile and payload.pages: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="模型声明无需编译却返回了 Wiki 页面", + ) + return payload + + +def _apply_compilation_payload( + db: Session, + source_database_id: int, + payload: WikiCompilationPayload, + *, + editable_slugs: set[str], +) -> int: + """校验并原子写入模型生成的页面、追溯关系和来源状态。""" + db.expire_all() + source = ( + db.query(ProjectWikiSource) + .filter(ProjectWikiSource.id == source_database_id) + .with_for_update() + .first() + ) + if source is None or source.status != "compiling": + db.rollback() + return 0 + + existing_pages = ( + db.query(ProjectWikiPage) + .filter(ProjectWikiPage.project_id == source.project_id) + .all() + ) + existing_by_slug = {page.slug: page for page in existing_pages} + existing_title_map = { + _normalize_title_key(page.title): page + for page in existing_pages + } + normalized_payloads: dict[str, Any] = {} + for page_payload in payload.pages: + slug = _normalize_page_slug(page_payload.slug, page_payload.title) + title_match = existing_title_map.get(_normalize_title_key(page_payload.title)) + if title_match is not None: + slug = title_match.slug + if slug in existing_by_slug and slug not in editable_slugs: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"模型尝试更新未提供完整上下文的页面 {slug}", + ) + normalized_payloads[slug] = page_payload + + all_project_source_ids = { + item[0] + for item in db.query(ProjectWikiSource.source_id).filter( + ProjectWikiSource.project_id == source.project_id + ) + } + allowed_targets = ( + SYSTEM_WIKI_TARGETS + | set(existing_by_slug) + | set(normalized_payloads) + | all_project_source_ids + ) + page_count = 0 + now = datetime.now(timezone.utc) + for slug, page_payload in normalized_payloads.items(): + content = wiki_vault.sanitize_wiki_links( + page_payload.content_markdown.strip(), + allowed_targets, + ) + if not content.startswith("# "): + content = f"# {page_payload.title.strip()}\n\n{content}" + if f"[[{source.source_id}" not in content: + content += ( + "\n\n## 相关来源\n\n" + f"- [[{source.source_id}|{source.title}]]" + ) + + page = existing_by_slug.get(slug) + if page is None: + page = ProjectWikiPage( + project_id=source.project_id, + slug=slug, + page_type=page_payload.page_type, + title=page_payload.title.strip(), + summary=page_payload.summary.strip(), + content_markdown=content, + status="active", + revision=1, + ) + db.add(page) + db.flush() + existing_by_slug[slug] = page + else: + page.page_type = page_payload.page_type + page.title = page_payload.title.strip() + page.summary = page_payload.summary.strip() + page.content_markdown = content + page.status = "active" + page.revision += 1 + + relation = ( + db.query(ProjectWikiPageSource) + .filter( + ProjectWikiPageSource.page_id == page.id, + ProjectWikiPageSource.source_id == source.id, + ProjectWikiPageSource.relation_type == page_payload.relation_type, + ) + .first() + ) + if relation is None: + db.add( + ProjectWikiPageSource( + page_id=page.id, + source_id=source.id, + relation_type=page_payload.relation_type, + ) + ) + page_count += 1 + + source.compiled_markdown = wiki_vault.sanitize_wiki_links( + payload.source_note_markdown.strip(), + allowed_targets, + ) + source.status = "compiled" + source.error_message = None + source.compiled_at = now + db.commit() + return page_count + + +def _build_page_context( + db: Session, + project_id: int, + source_content: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], set[str]]: + """生成现有页面索引,并选择可被模型安全更新的相关页面全文。""" + pages = ( + db.query(ProjectWikiPage) + .filter(ProjectWikiPage.project_id == project_id) + .order_by(ProjectWikiPage.updated_at.desc(), ProjectWikiPage.id.desc()) + .limit(PAGE_INDEX_LIMIT) + .all() + ) + scored_pages = sorted( + pages, + key=lambda page: ( + _page_relevance_score(page, source_content), + page.updated_at or page.created_at, + ), + reverse=True, + ) + editable = scored_pages[:PAGE_CONTEXT_LIMIT] + editable_slugs = {page.slug for page in editable} + page_index = [ + { + "slug": page.slug, + "page_type": page.page_type, + "title": page.title, + "summary": page.summary, + "editable": page.slug in editable_slugs, + } + for page in pages + ] + editable_pages = [ + { + "slug": page.slug, + "page_type": page.page_type, + "title": page.title, + "summary": page.summary, + "content_markdown": page.content_markdown, + "revision": page.revision, + } + for page in editable + ] + return page_index, editable_pages, editable_slugs + + +def _page_relevance_score(page: ProjectWikiPage, source_content: str) -> int: + """按标题和摘要词在来源中的出现情况计算轻量相关度。""" + normalized_source = source_content.lower() + score = 0 + title = page.title.strip().lower() + if title and title in normalized_source: + score += 20 + tokens = { + token.lower() + for token in re.findall(r"[A-Za-z0-9_\-]{2,}|[\u4e00-\u9fff]{2,}", f"{page.title} {page.summary}") + } + for token in tokens: + if token in normalized_source: + score += min(5, normalized_source.count(token)) + return score + + +def _split_source_content(content: str) -> tuple[list[str], bool]: + """按配置把长来源切成有上限的文本分段。""" + chunk_size = max(2000, settings.WIKI_SOURCE_CHUNK_CHARS) + max_chunks = max(1, settings.WIKI_MAX_CHUNKS_PER_SOURCE) + if not content: + return ["[来源正文为空]"], False + all_chunks = [ + content[index : index + chunk_size] + for index in range(0, len(content), chunk_size) + ] + return all_chunks[:max_chunks], len(all_chunks) > max_chunks + + +def _parse_compilation_payload(raw_output: str) -> WikiCompilationPayload: + """解析并严格校验模型的 Wiki 编译 JSON。""" + normalized = raw_output.strip() + if normalized.startswith("```"): + normalized = re.sub(r"^```(?:json)?\s*", "", normalized, flags=re.IGNORECASE) + normalized = re.sub(r"\s*```$", "", normalized) + try: + payload = json.loads(normalized) + return WikiCompilationPayload.model_validate(payload) + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"大模型 Wiki 编译结果未通过结构化校验:{str(exc)[:300]}", + ) from exc + + +def _source_prompt_metadata(source: WikiSourceCompileSnapshot) -> dict[str, Any]: + """构造不含敏感凭据的来源编译元数据。""" + return { + "source_id": source.source_id, + "title": source.title, + "source_type": source.source_type, + "origin_kind": source.origin_kind, + "source_uri": source.source_uri, + "trust_level": source.trust_level, + "occurred_at": source.occurred_at, + } + + +def _mark_source_failed( + db: Session, + source_database_id: int, + error_message: str, +) -> None: + """回滚当前事务并把仍在编译的来源标记为可重试失败。""" + db.rollback() + source = ( + db.query(ProjectWikiSource) + .filter(ProjectWikiSource.id == source_database_id) + .first() + ) + if source is not None and source.status == "compiling": + source.status = "failed" + source.error_message = error_message[:2000] + db.commit() + + +def _build_unique_source_id(db: Session, project_id: int, snapshot_hash: str) -> str: + """按哈希前缀生成项目内唯一且稳定的来源ID。""" + for prefix_length in (12, 16, 24, 32, 64): + source_id = f"src_{snapshot_hash[:prefix_length]}" + existing = ( + db.query(ProjectWikiSource) + .filter( + ProjectWikiSource.project_id == project_id, + ProjectWikiSource.source_id == source_id, + ) + .first() + ) + if existing is None or existing.snapshot_hash == snapshot_hash: + return source_id + raise RuntimeError("无法为 Wiki 来源生成唯一ID") + + +def _normalize_page_slug(raw_slug: str, title: str) -> str: + """清理模型返回的页面 slug,并为非法值生成稳定后备值。""" + normalized = raw_slug.strip().lower().replace("_", "-") + normalized = re.sub(r"\s+", "-", normalized) + normalized = re.sub(r"[^a-z0-9\-\u4e00-\u9fff]", "", normalized) + normalized = re.sub(r"-{2,}", "-", normalized).strip("-")[:140] + if not normalized: + digest = hashlib.sha256(title.strip().encode("utf-8")).hexdigest()[:12] + normalized = f"page-{digest}" + if normalized in SYSTEM_WIKI_TARGETS or normalized.startswith("src_"): + normalized = f"page-{normalized}" + return normalized + + +def _normalize_title_key(title: str) -> str: + """生成用于检测近似同名页面的标题键。""" + return re.sub(r"[\s\-_—–::,,。.!!??()()]+", "", title).lower() + + +def _format_compilation_error(exc: Exception) -> str: + """把编译异常转换成不泄露凭据的简短错误。""" + if isinstance(exc, HTTPException): + return str(exc.detail)[:1000] + return f"{exc.__class__.__name__}: {str(exc)[:900]}" + + +def _get_project(db: Session, project_id: int) -> Project: + """获取项目,不存在时抛出统一业务异常。""" + project = db.query(Project).filter(Project.id == project_id).first() + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + return project + + +def _clean_optional_text(value: str | None) -> str | None: + """清理可选字符串。""" + if value is None: + return None + normalized = value.strip() + return normalized or None + + +def _load_string_list(value: str | None) -> list[str]: + """安全解析字符串列表 JSON。""" + if not value: + return [] + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + return [str(item) for item in parsed] diff --git a/backend/app/services/wiki_extract.py b/backend/app/services/wiki_extract.py new file mode 100644 index 0000000..c97aaaa --- /dev/null +++ b/backend/app/services/wiki_extract.py @@ -0,0 +1,211 @@ +"""项目 Wiki 文档文本提取。""" + +from dataclasses import dataclass +from pathlib import Path +import re +from zipfile import BadZipFile, ZipFile + +from openpyxl import load_workbook + +from app.config import settings +from app.models.project import ProjectDocumentFile + + +TEXT_SUFFIXES = { + ".md", + ".markdown", + ".txt", + ".json", + ".csv", + ".log", + ".xml", + ".yaml", + ".yml", + ".ini", + ".cfg", +} +ZIP_XML_LIMIT_BYTES = 20 * 1024 * 1024 +XLSX_MAX_SHEETS = 30 +XLSX_MAX_ROWS_PER_SHEET = 5000 +XLSX_MAX_COLUMNS_PER_ROW = 100 +PDF_MAX_PAGES = 500 + + +@dataclass(frozen=True) +class DocumentExtractionResult: + """项目文档文本提取结果。""" + + content: str + status: str + error_message: str | None = None + + +def extract_project_document(document: ProjectDocumentFile) -> DocumentExtractionResult: + """按文档类型提取可供 Wiki 编译的文本。""" + absolute_path = Path(settings.DOCUMENTS_DIR) / Path(document.file_path) + if not absolute_path.exists() or not absolute_path.is_file(): + return DocumentExtractionResult("", "unsupported", "项目文档文件不存在") + if absolute_path.stat().st_size > settings.WIKI_MAX_DOCUMENT_BYTES: + return DocumentExtractionResult( + "", + "unsupported", + f"文件超过知识提取上限 {settings.WIKI_MAX_DOCUMENT_BYTES} 字节", + ) + + suffix = Path(document.original_name or document.file_name).suffix.lower() + try: + if suffix in TEXT_SUFFIXES: + content = _decode_text(absolute_path.read_bytes()) + elif suffix == ".docx": + content = _extract_docx(absolute_path) + elif suffix == ".pptx": + content = _extract_pptx(absolute_path) + elif suffix in {".xlsx", ".xlsm"}: + content = _extract_xlsx(absolute_path) + elif suffix == ".pdf": + content = _extract_pdf(absolute_path) + else: + return DocumentExtractionResult( + "", + "unsupported", + f"暂不支持提取 {suffix or '未知格式'} 文档", + ) + except (BadZipFile, OSError, ValueError, RuntimeError) as exc: + return DocumentExtractionResult( + "", + "unsupported", + f"文档文本提取失败:{str(exc)[:300]}", + ) + + normalized = _normalize_extracted_text(content) + if not normalized: + return DocumentExtractionResult( + "", + "unsupported", + "未提取到可编译文本,扫描件或图片文档需要接入 OCR", + ) + if len(normalized) > settings.WIKI_MAX_EXTRACTED_CHARS: + normalized = normalized[: settings.WIKI_MAX_EXTRACTED_CHARS] + normalized += "\n\n[文本提取已达到 TinyPM 长度上限,后续内容未进入本次来源快照]" + return DocumentExtractionResult(normalized, "inbox") + + +def _decode_text(raw_bytes: bytes) -> str: + """按常见项目文档编码解码文本。""" + for encoding in ("utf-8-sig", "utf-8", "gb18030"): + try: + return raw_bytes.decode(encoding) + except UnicodeDecodeError: + continue + raise ValueError("文本编码不是 UTF-8 或 GB18030") + + +def _extract_docx(path: Path) -> str: + """从 DOCX 的正文 XML 提取段落文本。""" + with ZipFile(path) as archive: + xml_text = _read_zip_xml(archive, "word/document.xml") + paragraphs = re.split(r"", xml_text) + lines = [_extract_xml_text(paragraph) for paragraph in paragraphs] + return "\n".join(line for line in lines if line) + + +def _extract_pptx(path: Path) -> str: + """从 PPTX 的各页 XML 提取文本。""" + with ZipFile(path) as archive: + slide_names = sorted( + ( + name + for name in archive.namelist() + if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) + ), + key=lambda name: int(re.search(r"\d+", Path(name).stem).group()), + ) + sections: list[str] = [] + for index, slide_name in enumerate(slide_names, start=1): + slide_text = _extract_xml_text(_read_zip_xml(archive, slide_name)) + if slide_text: + sections.append(f"## 幻灯片 {index}\n\n{slide_text}") + return "\n\n".join(sections) + + +def _read_zip_xml(archive: ZipFile, name: str) -> str: + """受限读取 Office 压缩包中的单个 XML。""" + try: + info = archive.getinfo(name) + except KeyError as exc: + raise ValueError(f"Office 文档缺少 {name}") from exc + if info.file_size > ZIP_XML_LIMIT_BYTES: + raise ValueError(f"Office XML {name} 超过安全提取上限") + return archive.read(info).decode("utf-8", errors="replace") + + +def _extract_xml_text(xml_text: str) -> str: + """提取 Office XML 文本节点并保留基本换行。""" + text = re.sub(r"<(?:w:tab|a:tab)[^>]*/>", "\t", xml_text) + text = re.sub(r"<(?:w:br|a:br)[^>]*/>", "\n", text) + fragments = re.findall(r"<(?:w:t|a:t)(?:\s[^>]*)?>(.*?)", text, re.DOTALL) + return " ".join(_unescape_xml(fragment) for fragment in fragments).strip() + + +def _unescape_xml(value: str) -> str: + """还原 Office 文本节点中的常见 XML 实体。""" + return ( + value.replace("<", "<") + .replace(">", ">") + .replace(""", '"') + .replace("'", "'") + .replace("&", "&") + ) + + +def _extract_xlsx(path: Path) -> str: + """从 XLSX 提取受限的单元格文本。""" + workbook = load_workbook(path, read_only=True, data_only=True) + sections: list[str] = [] + try: + for worksheet in workbook.worksheets[:XLSX_MAX_SHEETS]: + rows: list[str] = [] + for row_index, row in enumerate( + worksheet.iter_rows(values_only=True), + start=1, + ): + if row_index > XLSX_MAX_ROWS_PER_SHEET: + rows.append("[该工作表后续行未提取]") + break + values = [ + str(value).strip() if value is not None else "" + for value in row[:XLSX_MAX_COLUMNS_PER_ROW] + ] + if any(values): + rows.append(" | ".join(values).rstrip(" |")) + if rows: + sections.append(f"## 工作表:{worksheet.title}\n\n" + "\n".join(rows)) + finally: + workbook.close() + return "\n\n".join(sections) + + +def _extract_pdf(path: Path) -> str: + """从带文本层的 PDF 提取页面文本。""" + try: + from pypdf import PdfReader + except ImportError as exc: + raise RuntimeError("后端尚未安装 PDF 文本提取依赖") from exc + + reader = PdfReader(str(path)) + sections: list[str] = [] + for index, page in enumerate(reader.pages[:PDF_MAX_PAGES], start=1): + page_text = (page.extract_text() or "").strip() + if page_text: + sections.append(f"## PDF 第 {index} 页\n\n{page_text}") + if len(reader.pages) > PDF_MAX_PAGES: + sections.append("[PDF 后续页面未提取]") + return "\n\n".join(sections) + + +def _normalize_extracted_text(content: str) -> str: + """清理提取文本中的空白和控制字符。""" + normalized = content.replace("\x00", "").replace("\r\n", "\n").replace("\r", "\n") + normalized = re.sub(r"[ \t]+\n", "\n", normalized) + normalized = re.sub(r"\n{4,}", "\n\n\n", normalized) + return normalized.strip() diff --git a/backend/app/services/wiki_vault.py b/backend/app/services/wiki_vault.py new file mode 100644 index 0000000..4871e47 --- /dev/null +++ b/backend/app/services/wiki_vault.py @@ -0,0 +1,445 @@ +"""项目 Wiki 的 Obsidian 投影与完整性检查。""" + +import csv +from collections.abc import Iterable +from io import BytesIO, StringIO +import json +from pathlib import Path +import re +from typing import Any +from zipfile import ZIP_DEFLATED, ZipFile + +from sqlalchemy.orm import Session, selectinload + +from app.config import settings +from app.models.project import Project +from app.models.wiki import ( + ProjectWikiCompilationRun, + ProjectWikiPage, + ProjectWikiPageSource, + ProjectWikiSource, +) + + +PAGE_DIRECTORY_MAP = { + "topic": "topics", + "concept": "concepts", + "synthesis": "synthesis", + "idea": "ideas", +} +WIKI_LINK_PATTERN = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]") + + +def write_project_vault(db: Session, project_id: int) -> Path: + """从数据库重新生成项目的 Obsidian 兼容目录。""" + project = db.query(Project).filter(Project.id == project_id).first() + if project is None: + raise ValueError(f"项目ID {project_id} 不存在") + + sources = ( + db.query(ProjectWikiSource) + .filter(ProjectWikiSource.project_id == project_id) + .options( + selectinload(ProjectWikiSource.page_links).selectinload( + ProjectWikiPageSource.page + ) + ) + .order_by(ProjectWikiSource.imported_at.asc(), ProjectWikiSource.id.asc()) + .all() + ) + pages = ( + db.query(ProjectWikiPage) + .filter(ProjectWikiPage.project_id == project_id) + .options( + selectinload(ProjectWikiPage.source_links).selectinload( + ProjectWikiPageSource.source + ) + ) + .order_by(ProjectWikiPage.page_type.asc(), ProjectWikiPage.title.asc()) + .all() + ) + runs = ( + db.query(ProjectWikiCompilationRun) + .filter(ProjectWikiCompilationRun.project_id == project_id) + .order_by(ProjectWikiCompilationRun.started_at.desc()) + .limit(100) + .all() + ) + + vault_root = Path(settings.DOCUMENTS_DIR) / "wiki" / f"project-{project_id}" + directories = [ + vault_root / "raw" / "sources", + vault_root / "wiki" / "sources", + vault_root / "wiki" / "topics", + vault_root / "wiki" / "concepts", + vault_root / "wiki" / "synthesis", + vault_root / "wiki" / "ideas", + vault_root / "manifests", + ] + for directory in directories: + directory.mkdir(parents=True, exist_ok=True) + + for source in sources: + _atomic_write_text( + vault_root / "raw" / "sources" / f"{source.source_id}.md", + source.raw_content, + ) + _atomic_write_text( + vault_root / "wiki" / "sources" / f"{source.source_id}.md", + _render_source_note(source), + ) + + for page in pages: + directory = PAGE_DIRECTORY_MAP.get(page.page_type, "synthesis") + _atomic_write_text( + vault_root / "wiki" / directory / f"{page.slug}.md", + _render_wiki_page(page), + ) + + _atomic_write_text( + vault_root / "wiki" / "index.md", + _render_index(project.name, sources, pages), + ) + _atomic_write_text( + vault_root / "wiki" / "current-status.md", + _render_current_status(project.name, sources, pages, runs), + ) + _atomic_write_text( + vault_root / "wiki" / "log.md", + _render_log(runs), + ) + _atomic_write_text( + vault_root / "wiki" / "ideas.md", + _render_ideas_index(pages), + ) + _atomic_write_text( + vault_root / "manifests" / "raw_sources.csv", + _render_manifest(sources), + ) + return vault_root + + +def build_project_vault_zip(db: Session, project_id: int) -> bytes: + """生成项目 Wiki 的 ZIP 导出内容。""" + vault_root = write_project_vault(db, project_id) + archive_buffer = BytesIO() + with ZipFile(archive_buffer, "w", compression=ZIP_DEFLATED) as archive: + for path in sorted(vault_root.rglob("*")): + if not path.is_file() or path.suffix == ".tmp": + continue + archive.write(path, arcname=str(path.relative_to(vault_root)).replace("\\", "/")) + return archive_buffer.getvalue() + + +def lint_project_wiki(db: Session, project_id: int) -> list[str]: + """检查 Wiki 页面引用、来源引用和页面追溯关系。""" + sources = ( + db.query(ProjectWikiSource) + .filter(ProjectWikiSource.project_id == project_id) + .all() + ) + pages = ( + db.query(ProjectWikiPage) + .filter(ProjectWikiPage.project_id == project_id) + .options(selectinload(ProjectWikiPage.source_links)) + .all() + ) + allowed_targets = { + "index", + "current-status", + "log", + "ideas", + *(source.source_id for source in sources), + *(page.slug for page in pages), + } + + errors: list[str] = [] + for page in pages: + if not page.source_links: + errors.append(f"页面 {page.slug} 没有来源追溯关系") + for target in _extract_wiki_link_targets(page.content_markdown): + if target not in allowed_targets: + errors.append(f"页面 {page.slug} 存在断链:{target}") + for source in sources: + for target in _extract_wiki_link_targets(source.compiled_markdown or ""): + if target not in allowed_targets: + errors.append(f"来源 {source.source_id} 存在断链:{target}") + return sorted(set(errors)) + + +def sanitize_wiki_links(content: str, allowed_targets: set[str]) -> str: + """保留有效 Obsidian 链接,并将未知目标降级为普通文本。""" + def replace_link(match: re.Match[str]) -> str: + raw_link = match.group(0) + target = match.group(1).strip() + if target in allowed_targets: + return raw_link + inner = raw_link[2:-2] + if "|" in inner: + return inner.split("|", 1)[1].strip() + return target + + return WIKI_LINK_PATTERN.sub(replace_link, content) + + +def _render_source_note(source: ProjectWikiSource) -> str: + """渲染单个来源说明页。""" + related_pages = sorted( + ( + link.page + for link in source.page_links + if link.page is not None + ), + key=lambda page: page.title, + ) + page_lines = [f"- [[{page.slug}|{page.title}]]" for page in related_pages] + compiled = source.compiled_markdown or "尚未由 LLM 编译。" + return "\n".join( + [ + "---", + "type: source", + f"source_id: {_yaml_value(source.source_id)}", + f"status: {_yaml_value(source.status)}", + f"origin_kind: {_yaml_value(source.origin_kind)}", + f"origin_ref: {_yaml_value(source.origin_ref)}", + f"source_type: {_yaml_value(source.source_type)}", + f"trust_level: {_yaml_value(source.trust_level)}", + f"content_hash: {_yaml_value(source.content_hash)}", + f"snapshot_hash: {_yaml_value(source.snapshot_hash)}", + f"occurred_at: {_yaml_value(source.occurred_at)}", + f"imported_at: {_yaml_value(source.imported_at)}", + "---", + "", + f"# {source.title}", + "", + "## 来源", + "", + f"- 原始地址:{source.source_uri or '未提供'}", + f"- 原文快照:[[../../raw/sources/{source.source_id}.md]]", + f"- 可信等级:{source.trust_level}", + "", + "## AI 编译", + "", + compiled, + "", + "## 相关 Wiki 页面", + "", + *(page_lines or ["- 暂无"]), + "", + ] + ) + + +def _render_wiki_page(page: ProjectWikiPage) -> str: + """渲染长期知识页面。""" + source_ids = sorted( + { + link.source.source_id + for link in page.source_links + if link.source is not None + } + ) + return "\n".join( + [ + "---", + f"type: {_yaml_value(page.page_type)}", + f"status: {_yaml_value(page.status)}", + f"revision: {page.revision}", + "sources:", + *(f" - {_yaml_value(source_id)}" for source_id in source_ids), + "---", + "", + page.content_markdown.strip(), + "", + ] + ) + + +def _render_index( + project_name: str, + sources: list[ProjectWikiSource], + pages: list[ProjectWikiPage], +) -> str: + """渲染 Wiki 总索引。""" + lines = [ + "---", + "type: index", + "status: active", + "---", + "", + f"# {project_name} 项目知识库", + "", + "- [[current-status|当前状态]]", + "- [[log|编译日志]]", + "- [[ideas|想法索引]]", + "", + ] + for page_type, title in ( + ("topic", "主题"), + ("concept", "概念"), + ("synthesis", "综合分析"), + ("idea", "想法"), + ): + lines.extend([f"## {title}", ""]) + matching = [page for page in pages if page.page_type == page_type] + lines.extend( + [f"- [[{page.slug}|{page.title}]] — {page.summary}" for page in matching] + or ["- 暂无"] + ) + lines.append("") + + lines.extend(["## 来源", ""]) + lines.extend( + [ + f"- [[{source.source_id}|{source.title}]] — {source.status} / {source.source_type}" + for source in sources + ] + or ["- 暂无"] + ) + lines.append("") + return "\n".join(lines) + + +def _render_current_status( + project_name: str, + sources: list[ProjectWikiSource], + pages: list[ProjectWikiPage], + runs: list[ProjectWikiCompilationRun], +) -> str: + """渲染当前状态页。""" + source_counts = _count_values(source.status for source in sources) + page_counts = _count_values(page.page_type for page in pages) + latest_run = runs[0] if runs else None + return "\n".join( + [ + "---", + "type: current-status", + "status: active", + "---", + "", + f"# {project_name} 知识库当前状态", + "", + "## 来源状态", + "", + *(f"- {key}: {value}" for key, value in sorted(source_counts.items())), + *([] if source_counts else ["- 暂无来源"]), + "", + "## 页面数量", + "", + *(f"- {key}: {value}" for key, value in sorted(page_counts.items())), + *([] if page_counts else ["- 暂无页面"]), + "", + "## 最近编译", + "", + ( + f"- 批次 {latest_run.id}: {latest_run.status}," + f"来源 {latest_run.source_count},页面更新 {latest_run.page_count}" + if latest_run + else "- 尚未编译" + ), + "", + ] + ) + + +def _render_log(runs: list[ProjectWikiCompilationRun]) -> str: + """渲染编译日志。""" + lines = ["---", "type: log", "status: active", "---", "", "# Wiki 编译日志", ""] + if not runs: + lines.extend(["尚无编译记录。", ""]) + return "\n".join(lines) + for run in runs: + lines.extend( + [ + f"## 批次 {run.id} · {run.status}", + "", + f"- 触发方式:{run.trigger}", + f"- 模型:{run.model or '未调用'}", + f"- 来源数量:{run.source_count}", + f"- 页面更新:{run.page_count}", + f"- 开始时间:{run.started_at}", + f"- 结束时间:{run.finished_at or '未结束'}", + f"- 错误:{run.error_message or '无'}", + "", + ] + ) + return "\n".join(lines) + + +def _render_ideas_index(pages: list[ProjectWikiPage]) -> str: + """渲染想法页面索引。""" + ideas = [page for page in pages if page.page_type == "idea"] + lines = ["---", "type: ideas-index", "status: active", "---", "", "# 想法索引", ""] + lines.extend( + [f"- [[{page.slug}|{page.title}]] — {page.summary}" for page in ideas] + or ["- 暂无可行动假设"] + ) + lines.append("") + return "\n".join(lines) + + +def _render_manifest(sources: list[ProjectWikiSource]) -> str: + """渲染来源清单 CSV。""" + output = StringIO(newline="") + writer = csv.writer(output, lineterminator="\n") + writer.writerow( + [ + "source_id", + "title", + "origin_kind", + "origin_ref", + "source_type", + "source_uri", + "content_hash", + "snapshot_hash", + "trust_level", + "occurred_at", + "imported_at", + "status", + ] + ) + for source in sources: + writer.writerow( + [ + source.source_id, + source.title, + source.origin_kind, + source.origin_ref or "", + source.source_type, + source.source_uri or "", + source.content_hash, + source.snapshot_hash, + source.trust_level, + source.occurred_at or "", + source.imported_at, + source.status, + ] + ) + return output.getvalue() + + +def _extract_wiki_link_targets(content: str) -> set[str]: + """提取 Markdown 中的 Obsidian 链接目标。""" + return {match.group(1).strip() for match in WIKI_LINK_PATTERN.finditer(content)} + + +def _count_values(values: Iterable[str]) -> dict[str, int]: + """统计字符串值出现次数。""" + counts: dict[str, int] = {} + for value in values: + counts[str(value)] = counts.get(str(value), 0) + 1 + return counts + + +def _yaml_value(value: Any) -> str: + """把标量编码成兼容 YAML 的 JSON 字符串。""" + if value is None: + return "null" + return json.dumps(str(value), ensure_ascii=False) + + +def _atomic_write_text(path: Path, content: str) -> None: + """原子写入 UTF-8 文本,避免产生半文件。""" + temporary_path = path.with_suffix(path.suffix + ".tmp") + temporary_path.write_text(content, encoding="utf-8") + temporary_path.replace(path) diff --git a/backend/app/wiki_worker.py b/backend/app/wiki_worker.py new file mode 100644 index 0000000..50aeb57 --- /dev/null +++ b/backend/app/wiki_worker.py @@ -0,0 +1,71 @@ +"""TinyPM 项目 Wiki 持久收件箱后台编译进程。""" + +import asyncio +import logging + +from app.config import settings +from app.database import SessionLocal +from app.migrations import run_migrations +from app.models.user import User +from app.services import wiki as wiki_service + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +async def run_worker() -> None: + """持续扫描数据库来源收件箱,并按项目串行触发增量编译。""" + run_migrations() + poll_seconds = max(2, min(settings.WIKI_WORKER_POLL_SECONDS, 60)) + logger.info("TinyPM Wiki worker 已启动,轮询间隔 %s 秒", poll_seconds) + + while True: + db = SessionLocal() + try: + recovered_count = wiki_service.recover_stale_compilations(db) + if recovered_count: + logger.warning("已恢复 %s 个中断的 Wiki 编译批次", recovered_count) + candidate = wiki_service.find_auto_compile_candidate(db) + if candidate is None: + db.close() + await asyncio.sleep(poll_seconds) + continue + + project_id, user_id = candidate + user = ( + db.query(User) + .filter(User.id == user_id, User.is_active.is_(True)) + .first() + ) + if user is None: + db.close() + await asyncio.sleep(poll_seconds) + continue + await wiki_service.compile_project_inbox( + db, + project_id, + user, + trigger="worker", + ) + except Exception: + logger.exception("Wiki worker 本轮执行失败") + db.rollback() + await asyncio.sleep(poll_seconds) + finally: + db.close() + + +def main() -> None: + """启动异步 Wiki worker。""" + try: + asyncio.run(run_worker()) + except KeyboardInterrupt: + logger.info("TinyPM Wiki worker 已停止") + + +if __name__ == "__main__": + main() diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..d50ca28 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt + +pytest==8.3.5 diff --git a/backend/requirements.txt b/backend/requirements.txt index 3f56694..45520b7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 sqlalchemy==2.0.35 +alembic==1.18.5 psycopg2-binary==2.9.9 pydantic==2.9.2 pydantic-settings==2.5.2 @@ -9,3 +10,4 @@ bcrypt==4.2.0 httpx==0.27.2 python-multipart==0.0.12 openpyxl==3.1.5 +pypdf==5.7.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..12051bd --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,117 @@ +"""后端 PostgreSQL 测试夹具。""" + +import os +from collections.abc import Generator +from urllib.parse import unquote, urlsplit +from uuid import uuid4 + +import pytest + + +TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL", "").strip() + + +def _validate_test_database_url(database_url: str) -> None: + """拒绝缺失、非 PostgreSQL 或可能指向正式库的测试连接。""" + if not database_url: + pytest.exit( + "必须显式设置 TEST_DATABASE_URL,测试不会连接 TinyPM 默认数据库", + returncode=4, + ) + + parsed = urlsplit(database_url) + database_name = unquote(parsed.path.lstrip("/")).lower() + if not parsed.scheme.startswith("postgresql"): + pytest.exit("TEST_DATABASE_URL 必须使用 PostgreSQL", returncode=4) + if not database_name or "test" not in database_name: + pytest.exit( + "TEST_DATABASE_URL 的数据库名必须包含 test,拒绝连接可能的正式数据库", + returncode=4, + ) + if database_name in {"tinypm", "postgres", "template0", "template1"}: + pytest.exit( + f"禁止把测试连接指向数据库 {database_name}", + returncode=4, + ) + + +_validate_test_database_url(TEST_DATABASE_URL) +os.environ["DATABASE_URL"] = TEST_DATABASE_URL + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from app.database import Base +from app.models import Project, User + + +@pytest.fixture(scope="session") +def test_engine() -> Generator[Engine, None, None]: + """在独立测试数据库中创建随机 schema,避免测试间和人工数据互相污染。""" + schema_name = f"tinypm_test_{uuid4().hex}" + admin_engine = create_engine(TEST_DATABASE_URL, pool_pre_ping=True) + + with admin_engine.begin() as connection: + current_database = connection.execute( + text("SELECT current_database()") + ).scalar_one() + if "test" not in current_database.lower() or current_database.lower() == "tinypm": + pytest.fail(f"安全检查失败,当前数据库为 {current_database}") + connection.execute(text(f'CREATE SCHEMA "{schema_name}"')) + + engine = create_engine( + TEST_DATABASE_URL, + pool_pre_ping=True, + connect_args={"options": f"-csearch_path={schema_name}"}, + ) + Base.metadata.create_all(bind=engine) + try: + yield engine + finally: + engine.dispose() + with admin_engine.begin() as connection: + connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')) + admin_engine.dispose() + + +@pytest.fixture() +def db(test_engine: Engine) -> Generator[Session, None, None]: + """提供允许业务代码自行提交事务的测试会话,并在用例后清空所有表。""" + session_factory = sessionmaker(bind=test_engine) + session = session_factory() + try: + yield session + finally: + session.close() + table_names = ", ".join( + f'"{table.name}"' for table in reversed(Base.metadata.sorted_tables) + ) + with test_engine.begin() as connection: + connection.execute(text(f"TRUNCATE TABLE {table_names} RESTART IDENTITY CASCADE")) + + +@pytest.fixture() +def manager(db: Session) -> User: + """创建具备周报审核权限的经理用户。""" + user = User( + username="weekly-test-manager", + display_name="周报测试经理", + password_hash="not-used-in-service-tests", + role="manager", + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture() +def project(db: Session) -> Project: + """创建最小项目数据。""" + item = Project(name="周报自动化测试项目") + db.add(item) + db.commit() + db.refresh(item) + return item diff --git a/backend/tests/test_risk_service.py b/backend/tests/test_risk_service.py new file mode 100644 index 0000000..8abcc55 --- /dev/null +++ b/backend/tests/test_risk_service.py @@ -0,0 +1,153 @@ +"""项目风险预警服务测试。""" + +from datetime import date, datetime + +from sqlalchemy.orm import Session + +from app.models.project import ( + Project, + ProjectIssue, + ProjectMilestone, + ProjectPlan, + ProjectPlanDependency, + ProjectRequirement, +) +from app.models.user import User +from app.services import project_mcp +from app.services import risk as risk_service + + +def test_risk_report_covers_schedule_dependency_quality_and_scope( + db: Session, + project: Project, +) -> None: + """应对进度、依赖、质量和范围风险给出可解释预警。""" + project.name = "风险规则测试项目" + project.status = "进行中" + project.planned_end_date = date(2026, 8, 2) + + predecessor = ProjectPlan( + project_id=project.id, + phase_name="开发", + primary_task="完成核心功能", + progress_pct=50, + planned_end=date(2026, 7, 20), + actual_start=date(2026, 7, 1), + status="进行中", + assignee="张三", + updated_at=datetime(2026, 7, 1), + ) + successor = ProjectPlan( + project_id=project.id, + phase_name="测试", + primary_task="执行系统测试", + progress_pct=10, + planned_end=date(2026, 8, 10), + actual_start=date(2026, 8, 1), + status="进行中", + assignee="李四", + updated_at=datetime(2026, 8, 1), + ) + db.add_all([predecessor, successor]) + db.flush() + db.add( + ProjectPlanDependency( + project_id=project.id, + predecessor_plan_id=predecessor.id, + successor_plan_id=successor.id, + dependency_type="FS", + lag_days=0, + ) + ) + db.add( + ProjectMilestone( + project_id=project.id, + name="SOP 交付", + planned_date=date(2026, 7, 31), + progress_pct=80, + status="进行中", + ) + ) + db.add( + ProjectIssue( + project_id=project.id, + title="升级后无法启动", + severity="严重", + status="待处理", + assignee="王五", + ) + ) + db.add( + ProjectRequirement( + project_id=project.id, + title="支持差分升级", + priority="高", + status="待评审", + owner="赵六", + updated_at=datetime(2026, 7, 1), + ) + ) + db.commit() + + report = risk_service.get_project_risk_report( + db, + project.id, + as_of=date(2026, 8, 3), + ) + + codes = {item["code"] for item in report["items"]} + assert report["overall_level"] == "高" + assert report["high_count"] >= 4 + assert { + "PROJECT_DEADLINE_OVERDUE", + "PLAN_OVERDUE", + "PLAN_DEPENDENCY_BLOCKED", + "MILESTONE_OVERDUE", + "CRITICAL_ISSUE_OPEN", + "HIGH_REQUIREMENT_PENDING", + }.issubset(codes) + assert all(item["evidence"] for item in report["items"]) + assert all(item["recommendation"] for item in report["items"]) + + +def test_completed_project_without_open_items_is_low_risk( + db: Session, + project: Project, +) -> None: + """已完成且没有打开事项的项目应返回空的低风险报告。""" + project.status = "已完成" + project.planned_end_date = date(2026, 1, 1) + db.commit() + + report = risk_service.get_project_risk_report( + db, + project.id, + as_of=date(2026, 8, 3), + ) + + assert report["overall_level"] == "低" + assert report["score"] == 0 + assert report["total_count"] == 0 + + +def test_agent_mcp_exposes_same_risk_report( + db: Session, + project: Project, + manager: User, +) -> None: + """Agent MCP 应复用同一风险规则结果。""" + project.status = "进行中" + project.planned_end_date = date(2026, 8, 1) + db.commit() + + result = project_mcp.call_tool( + db, + manager, + "get_project_risks", + {"project_id": project.id, "as_of": "2026-08-03"}, + ) + + report = result["risk_report"] + assert report["project_id"] == project.id + assert report["overall_level"] == "高" + assert report["items"][0]["code"] == "PROJECT_DEADLINE_OVERDUE" diff --git a/backend/tests/test_weekly_report_service.py b/backend/tests/test_weekly_report_service.py new file mode 100644 index 0000000..ca9ee76 --- /dev/null +++ b/backend/tests/test_weekly_report_service.py @@ -0,0 +1,2642 @@ +"""证据驱动周报 P0 的 PostgreSQL 行为测试。""" + +import asyncio +import hashlib +import json +import time +from queue import Queue +from threading import Barrier, Thread +from datetime import date, datetime, timedelta, timezone +from typing import Any +from uuid import uuid4 +from zoneinfo import ZoneInfo + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy import create_engine, inspect, text, update +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from app import migrations as migration_service + +from app.models.project import ( + Project, + ProjectIssue, + ProjectKnowledgeEntry, + ProjectMilestone, + ProjectPlan, + ProjectPlanDependency, + ProjectRequirement, +) +from app.schemas.project import KnowledgeEntryCreate, KnowledgeEntryUpdate +from app.models.user import User +from app.models.weekly_report import ( + ProjectWeeklyReport, + ProjectWeeklyReportEvidence, + ProjectWeeklyReportFact, + ProjectWeeklyReportVersion, +) +from app.schemas.weekly_report import ( + WeeklyReportApproveRequest, + WeeklyReportCreate, + WeeklyReportExternalFactCreate, + WeeklyReportFactCreate, +) +from app.services import project as project_service +from app.services import user as user_service +from app.services import weekly_report as weekly_report_service + + +WEEK_START = date(2026, 7, 6) +WEEK_END = date(2026, 7, 12) +SOURCE_SNAPSHOT_AT = datetime(2026, 7, 10, 8, tzinfo=timezone.utc) + + +def _create_report( + db: Session, + project_id: int, + manager: User, + *, + week_start: date = WEEK_START, + week_end: date = WEEK_END, +) -> ProjectWeeklyReport: + """创建固定周期周报,显式传入截止时间以避免依赖宿主机时区数据。""" + data = WeeklyReportCreate( + week_start=week_start, + week_end=week_end, + timezone="Asia/Shanghai", + cutoff_at=datetime.combine(week_end, datetime.max.time(), tzinfo=timezone.utc), + ) + return weekly_report_service.create_report(db, project_id, manager, data) + + +def _add_verified_fact( + db: Session, + report_id: int, + manager: User, + *, + category: str = "progress", + statement: str = "本期完成事实已由项目经理核验", + business_status: str | None = "正常", + owner: str | None = None, + next_action: str | None = None, + impact_description: str | None = None, + decision_required: bool = False, + decision_owner: str | None = None, + decision_due_at: date | None = None, + date_type: str | None = None, + date_value: date | None = None, + current_value: dict[str, object] | None = None, +) -> ProjectWeeklyReportFact: + """直接创建已核验事实,用于隔离测试确定性批准规则。""" + fact = ProjectWeeklyReportFact( + report_id=report_id, + category=category, + statement=statement, + business_status=business_status, + review_status="verified", + audience_scope="internal", + owner=owner, + next_action=next_action, + impact_description=impact_description, + decision_required=decision_required, + decision_owner=decision_owner, + decision_due_at=decision_due_at, + date_type=date_type, + date_value=date_value, + source_mode="manual", + created_by=manager.id, + verified_by=manager.id, + verified_at=datetime.now(timezone.utc), + current_value_json=( + json.dumps(current_value, ensure_ascii=False, sort_keys=True) + if current_value is not None + else None + ), + ) + db.add(fact) + db.flush() + db.add( + ProjectWeeklyReportEvidence( + fact_id=fact.id, + source_type="document", + source_title="测试证据", + source_excerpt="这是一条存在但不自动代表完成的证据。", + ) + ) + db.commit() + fact_id = fact.id + db.expire_all() + return weekly_report_service.get_fact(db, fact_id) + + +def _knowledge_entry( + project_id: int, + title: str, + occurred_at: datetime, +) -> ProjectKnowledgeEntry: + """构造带稳定正文哈希的知识条目。""" + content = f"{title} 的会议纪要内容" + return ProjectKnowledgeEntry( + project_id=project_id, + title=title, + source_type="meeting", + content=content, + tags_json="[]", + occurred_at=occurred_at, + content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(), + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + + +def _add_dependency_plan_pair( + db: Session, + project_id: int, +) -> tuple[ProjectPlan, ProjectPlan]: + """创建截止时间前可用于关键路径计算的前后计划。""" + predecessor = ProjectPlan( + project_id=project_id, + task_code="DEPENDENCY-PRE", + phase_name="依赖测试阶段", + secondary_task="前置计划", + duration_days=2, + progress_pct=20, + status="进行中", + assignee="前置负责人", + planned_start=WEEK_START, + planned_end=WEEK_START + timedelta(days=2), + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + successor = ProjectPlan( + project_id=project_id, + task_code="DEPENDENCY-SUCC", + phase_name="依赖测试阶段", + secondary_task="后续计划", + duration_days=3, + progress_pct=10, + status="进行中", + assignee="后续负责人", + planned_start=WEEK_START + timedelta(days=3), + planned_end=WEEK_END, + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + db.add_all([predecessor, successor]) + db.commit() + db.refresh(predecessor) + db.refresh(successor) + return predecessor, successor + + +def _add_draft_version( + db: Session, + report_id: int, + manager: User, + fact_ids: list[int], + content_markdown: str, +) -> ProjectWeeklyReportVersion: + """创建不依赖外部模型的统一周报草稿版本。""" + facts = [weekly_report_service.get_fact(db, fact_id) for fact_id in fact_ids] + version = ProjectWeeklyReportVersion( + report_id=report_id, + audience_type="canonical", + version_no=1, + version_stage="human_draft", + content_markdown=content_markdown, + fact_ids_json=json.dumps(fact_ids), + fact_hashes_json=json.dumps( + { + str(fact.id): weekly_report_service._fact_snapshot_hash(fact) + for fact in facts + }, + ensure_ascii=False, + sort_keys=True, + ), + prompt_version="1.0", + model_name=None, + generated_by=manager.id, + generated_at=datetime.now(timezone.utc), + ) + db.add(version) + db.commit() + db.refresh(version) + db.expire_all() + return version + + +def _generated_payload( + fact_id: int, + *, + summary: str = "本期已完成核验事项", + summary_fact_ids: list[int] | None = None, + item_text: str = "已核验事项按计划处理", + item_fact_ids: list[int] | None = None, +) -> dict[str, Any]: + """构造符合周报模型输出 Schema 的最小 JSON 对象。""" + return { + "title": "自动生成周报", + "summary": summary, + "summary_fact_ids": [fact_id] if summary_fact_ids is None else summary_fact_ids, + "sections": [ + { + "heading": "本周重点", + "items": [ + { + "text": item_text, + "fact_ids": [fact_id] if item_fact_ids is None else item_fact_ids, + } + ], + } + ], + } + + +def test_migration_chain_creates_weekly_report_and_wiki_schema( + test_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """空测试 schema 经启动迁移后必须包含周报字段和 Wiki 表。""" + schema_name = f"tinypm_migration_test_{uuid4().hex}" + admin_engine = create_engine(test_engine.url, pool_pre_ping=True) + migration_engine = create_engine( + test_engine.url, + pool_pre_ping=True, + connect_args={"options": f"-csearch_path={schema_name}"}, + ) + with admin_engine.begin() as connection: + connection.execute(text(f'CREATE SCHEMA "{schema_name}"')) + try: + monkeypatch.setattr(migration_service, "engine", migration_engine) + migration_service.run_migrations() + inspector = inspect(migration_engine) + report_columns = { + str(column["name"]): column + for column in inspector.get_columns("project_weekly_reports") + } + version_columns = { + str(column["name"]): column + for column in inspector.get_columns("project_weekly_report_versions") + } + with migration_engine.connect() as connection: + revision = connection.execute( + text("SELECT version_num FROM alembic_version") + ).scalar_one() + + assert revision == "0005_llm_wiki" + assert report_columns["coverage_json"]["nullable"] is False + assert version_columns["fact_hashes_json"]["nullable"] is False + assert "project_wiki_sources" in inspector.get_table_names() + assert "project_wiki_pages" in inspector.get_table_names() + assert "project_wiki_page_sources" in inspector.get_table_names() + assert "project_wiki_compilation_runs" in inspector.get_table_names() + finally: + migration_engine.dispose() + with admin_engine.begin() as connection: + connection.execute( + text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE') + ) + admin_engine.dispose() + + +def test_same_project_period_is_unique( + db: Session, + manager: User, + project: Project, +) -> None: + """相同项目和周期只能创建一份周报。""" + first = _create_report(db, project.id, manager) + + with pytest.raises(HTTPException) as exc_info: + _create_report(db, project.id, manager) + + assert exc_info.value.status_code == 409 + assert db.query(ProjectWeeklyReport).count() == 1 + assert db.query(ProjectWeeklyReport).one().id == first.id + + +def test_delete_user_preserves_weekly_report_audit_owner_and_allows_plain_user_delete( + db: Session, + manager: User, + project: Project, +) -> None: + """周报创建人只能停用不能删除,未被业务记录引用的普通用户仍可删除。""" + admin = User( + username="weekly-delete-admin", + display_name="删除测试管理员", + password_hash="not-used-in-service-tests", + role="admin", + is_active=True, + ) + plain_user = User( + username="weekly-plain-user", + display_name="未引用普通用户", + password_hash="not-used-in-service-tests", + role="member", + is_active=True, + ) + db.add_all([admin, plain_user]) + db.commit() + admin_id = admin.id + manager_id = manager.id + plain_user_id = plain_user.id + report = _create_report(db, project.id, manager) + report_id = report.id + + with pytest.raises(HTTPException) as exc_info: + user_service.delete_user(db, manager_id, admin) + + assert exc_info.value.status_code == 409 + assert "请改为停用账号" in exc_info.value.detail + assert db.get(User, manager_id) is not None + preserved_report = db.get(ProjectWeeklyReport, report_id) + assert preserved_report is not None + assert preserved_report.created_by == manager_id + + current_admin = db.get(User, admin_id) + assert current_admin is not None + user_service.delete_user(db, plain_user_id, current_admin) + assert db.get(User, plain_user_id) is None + + +def test_collect_is_idempotent_without_source_changes( + db: Session, + manager: User, + project: Project, +) -> None: + """重复采集同一来源不会制造重复事实或证据。""" + plan = ProjectPlan( + project_id=project.id, + task_code="T-001", + phase_name="开发", + secondary_task="完成周报工作台", + duration_days=5, + progress_pct=40, + status="进行中", + assignee="张三", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + db.add(plan) + db.commit() + report = _create_report(db, project.id, manager) + + first_report, _, first_created, _ = weekly_report_service.collect_report_inputs( + db, report.id, manager + ) + first_fact_ids = [fact.id for fact in first_report.facts] + first_evidence_count = db.query(ProjectWeeklyReportEvidence).count() + + second_report, _, second_created, _ = weekly_report_service.collect_report_inputs( + db, report.id, manager + ) + + assert first_created > 0 + assert second_created == 0 + assert [fact.id for fact in second_report.facts] == first_fact_ids + assert len({fact.source_key for fact in second_report.facts}) == len(second_report.facts) + assert db.query(ProjectWeeklyReportEvidence).count() == first_evidence_count + + +def test_historical_baseline_late_arrivals_use_business_dates( + db: Session, + manager: User, + project: Project, +) -> None: + """历史基线晚到补录按业务日期进入候选,需求清单不冒充本周进展。""" + post_cutoff_at = datetime(2026, 7, 13, 1, tzinfo=timezone.utc) + project.start_date = WEEK_START - timedelta(days=30) + project.created_at = post_cutoff_at + project.updated_at = post_cutoff_at + eligible_plan = ProjectPlan( + project_id=project.id, + task_code="ELIGIBLE-PLAN", + phase_name="截止前阶段", + secondary_task="截止前可用计划", + duration_days=2, + progress_pct=30, + status="进行中", + assignee="截止前负责人", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + late_plan = ProjectPlan( + project_id=project.id, + task_code="LATE-PLAN", + phase_name="截止后阶段", + secondary_task="截止后计划", + duration_days=1, + progress_pct=10, + status="进行中", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=post_cutoff_at, + updated_at=post_cutoff_at, + ) + late_milestone = ProjectMilestone( + project_id=project.id, + name="截止后里程碑", + planned_date=WEEK_END, + progress_pct=10, + status="进行中", + created_at=post_cutoff_at, + updated_at=post_cutoff_at, + ) + late_requirement = ProjectRequirement( + project_id=project.id, + req_id="REQ-LATE", + title="截止后需求", + priority="中", + status="开发中", + created_at=post_cutoff_at, + updated_at=post_cutoff_at, + ) + late_issue = ProjectIssue( + project_id=project.id, + title="截止后问题", + severity="一般", + status="待处理", + created_at=post_cutoff_at, + updated_at=post_cutoff_at, + ) + late_knowledge = _knowledge_entry( + project.id, + "截止后知识资料", + datetime(2026, 7, 8, 10, tzinfo=timezone.utc), + ) + late_knowledge.created_at = post_cutoff_at + late_knowledge.updated_at = post_cutoff_at + db.add_all( + [ + eligible_plan, + late_plan, + late_milestone, + late_requirement, + late_issue, + late_knowledge, + ] + ) + db.commit() + report = _create_report(db, project.id, manager) + + collected, coverage, created_count, _ = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + source_keys = {fact.source_key for fact in collected.facts} + facts_by_source = {fact.source_key: fact for fact in collected.facts} + + expected_late_sources = { + f"plan:{late_plan.id}:progress", + f"milestone:{late_milestone.id}:status", + f"issue:{late_issue.id}:status", + f"knowledge:{late_knowledge.id}:candidate", + } + assert expected_late_sources <= source_keys + assert f"plan:{eligible_plan.id}:progress" in source_keys + assert not any( + source_key and source_key.startswith(f"requirement:{late_requirement.id}:") + for source_key in source_keys + ) + assert coverage["late_arrival_source_count"] == 4 + assert coverage["post_cutoff_source_count"] == 0 + assert coverage["critical_path_unavailable_count"] == 0 + assert coverage["requirement_count"] == 0 + assert coverage["critical_plan_count"] > 0 + assert created_count == len(collected.facts) + assert all( + facts_by_source[source_key].review_status in {"pending_confirmation", "needs_judgment"} + for source_key in expected_late_sources + ) + assert all( + facts_by_source[source_key].source_mode.endswith("_late_arrival") + for source_key in expected_late_sources + ) + persisted_report = weekly_report_service.get_report(db, report.id) + assert json.loads(persisted_report.coverage_json) == coverage + assert weekly_report_service.serialize_report( + persisted_report, + include_detail=True, + )["coverage"] == coverage + validation = weekly_report_service.validate_report(db, report.id) + assert any( + "有 4 条资料在截止时间后补录" in warning + for warning in validation["warnings"] + ) + assert not any( + "因无法还原截止时快照" in warning + for warning in validation["warnings"] + ) + + +def test_existing_source_changed_after_cutoff_is_excluded( + db: Session, + manager: User, + project: Project, +) -> None: + """截止时已存在但随后被改写的来源仍需排除,避免污染历史快照。""" + changed_plan = ProjectPlan( + project_id=project.id, + task_code="CHANGED-AFTER-CUTOFF", + phase_name="快照保护阶段", + secondary_task="截止后改写计划", + duration_days=2, + progress_pct=60, + status="进行中", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=SOURCE_SNAPSHOT_AT, + updated_at=datetime(2026, 7, 13, 1, tzinfo=timezone.utc), + ) + db.add(changed_plan) + db.commit() + report = _create_report(db, project.id, manager) + + collected, coverage, created_count, _ = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + + assert created_count == 0 + assert collected.facts == [] + assert coverage["late_arrival_source_count"] == 0 + assert coverage["post_cutoff_source_count"] == 1 + assert coverage["critical_path_unavailable_count"] == 1 + validation = weekly_report_service.validate_report(db, report.id) + assert any( + "因无法还原截止时快照" in warning + for warning in validation["warnings"] + ) + + +def test_non_historical_project_new_plan_after_cutoff_is_excluded( + db: Session, + project: Project, +) -> None: + """普通项目在截止后新建的计划不能被误判为历史基线补录。""" + new_plan = ProjectPlan( + project_id=project.id, + task_code="NEW-AFTER-CUTOFF", + phase_name="普通项目阶段", + secondary_task="截止后新增计划", + duration_days=2, + progress_pct=0, + status="待开始", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=datetime(2026, 7, 13, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 7, 13, 1, tzinfo=timezone.utc), + ) + db.add(new_plan) + db.commit() + + preview = weekly_report_service.preview_weekly_inputs( + db, + project.id, + WEEK_START, + WEEK_END, + "Asia/Shanghai", + datetime.combine(WEEK_END, datetime.max.time(), tzinfo=timezone.utc), + ) + + assert preview["candidates"] == [] + assert preview["coverage"]["late_arrival_source_count"] == 0 + assert preview["coverage"]["post_cutoff_source_count"] == 1 + + +def test_legacy_coverage_defaults_late_arrival_count_to_zero() -> None: + """旧周报覆盖快照缺少新字段时仍能兼容读取。""" + coverage = weekly_report_service._load_coverage_snapshot( + json.dumps({"plan_count": 2, "post_cutoff_source_count": 1}) + ) + + assert coverage["plan_count"] == 2 + assert coverage["post_cutoff_source_count"] == 1 + assert coverage["late_arrival_source_count"] == 0 + + +def test_post_cutoff_dependency_is_counted_and_disables_critical_path( + db: Session, + project: Project, +) -> None: + """截止时间后新建的依赖边计入覆盖缺口并抑制关键路径候选。""" + predecessor, successor = _add_dependency_plan_pair(db, project.id) + db.add( + ProjectPlanDependency( + project_id=project.id, + predecessor_plan_id=predecessor.id, + successor_plan_id=successor.id, + dependency_type="FS", + lag_days=0, + created_at=datetime(2026, 7, 13, 1, tzinfo=timezone.utc), + ) + ) + db.commit() + + preview = weekly_report_service.preview_weekly_inputs( + db, + project.id, + WEEK_START, + WEEK_END, + "Asia/Shanghai", + datetime.combine(WEEK_END, datetime.max.time(), tzinfo=timezone.utc), + ) + source_keys = {candidate["source_key"] for candidate in preview["candidates"]} + + assert preview["coverage"]["post_cutoff_source_count"] == 1 + assert preview["coverage"]["critical_path_unavailable_count"] == 1 + assert preview["coverage"]["critical_plan_count"] == 0 + assert not any(source_key.startswith("critical_path:") for source_key in source_keys) + + +def test_create_dependency_advances_plans_and_suppresses_historical_critical_path( + db: Session, + project: Project, +) -> None: + """新增依赖边推进两端计划更新时间,使历史截止周报不计算关键路径。""" + predecessor, successor = _add_dependency_plan_pair(db, project.id) + cutoff_at = datetime.combine(WEEK_END, datetime.max.time(), tzinfo=timezone.utc) + + project_service.create_plan_dependency( + db, + project.id, + successor.id, + predecessor.id, + ) + db.expire_all() + current_predecessor = db.get(ProjectPlan, predecessor.id) + current_successor = db.get(ProjectPlan, successor.id) + preview = weekly_report_service.preview_weekly_inputs( + db, + project.id, + WEEK_START, + WEEK_END, + "Asia/Shanghai", + cutoff_at, + ) + + assert current_predecessor is not None + assert current_successor is not None + assert current_predecessor.updated_at > cutoff_at + assert current_successor.updated_at > cutoff_at + assert preview["coverage"]["post_cutoff_source_count"] == 3 + assert preview["coverage"]["critical_path_unavailable_count"] == 1 + assert not any( + candidate["source_key"].startswith("critical_path:") + for candidate in preview["candidates"] + ) + + +def test_delete_dependency_advances_plans_and_suppresses_historical_critical_path( + db: Session, + project: Project, +) -> None: + """删除依赖边推进两端计划更新时间,使历史截止周报不计算关键路径。""" + predecessor, successor = _add_dependency_plan_pair(db, project.id) + dependency = ProjectPlanDependency( + project_id=project.id, + predecessor_plan_id=predecessor.id, + successor_plan_id=successor.id, + dependency_type="FS", + lag_days=0, + created_at=SOURCE_SNAPSHOT_AT, + ) + db.add(dependency) + db.commit() + dependency_id = dependency.id + cutoff_at = datetime.combine(WEEK_END, datetime.max.time(), tzinfo=timezone.utc) + + project_service.delete_plan_dependency(db, dependency_id) + db.expire_all() + current_predecessor = db.get(ProjectPlan, predecessor.id) + current_successor = db.get(ProjectPlan, successor.id) + preview = weekly_report_service.preview_weekly_inputs( + db, + project.id, + WEEK_START, + WEEK_END, + "Asia/Shanghai", + cutoff_at, + ) + + assert db.get(ProjectPlanDependency, dependency_id) is None + assert current_predecessor is not None + assert current_successor is not None + assert current_predecessor.updated_at > cutoff_at + assert current_successor.updated_at > cutoff_at + assert preview["coverage"]["post_cutoff_source_count"] == 2 + assert preview["coverage"]["critical_path_unavailable_count"] == 1 + assert not any( + candidate["source_key"].startswith("critical_path:") + for candidate in preview["candidates"] + ) + + +def test_sync_predecessors_advances_successor_and_suppresses_historical_critical_path( + db: Session, + project: Project, +) -> None: + """同步前置依赖推进后续计划更新时间,使历史截止周报不计算关键路径。""" + predecessor, successor = _add_dependency_plan_pair(db, project.id) + cutoff_at = datetime.combine(WEEK_END, datetime.max.time(), tzinfo=timezone.utc) + + project_service.update_plan( + db, + successor.id, + {"predecessor_plan_ids": [predecessor.id]}, + ) + db.expire_all() + current_predecessor = db.get(ProjectPlan, predecessor.id) + current_successor = db.get(ProjectPlan, successor.id) + preview = weekly_report_service.preview_weekly_inputs( + db, + project.id, + WEEK_START, + WEEK_END, + "Asia/Shanghai", + cutoff_at, + ) + + assert current_predecessor is not None + assert current_successor is not None + assert current_predecessor.updated_at <= cutoff_at + assert current_successor.updated_at > cutoff_at + assert preview["coverage"]["post_cutoff_source_count"] == 2 + assert preview["coverage"]["critical_path_unavailable_count"] == 1 + assert not any( + candidate["source_key"].startswith("critical_path:") + for candidate in preview["candidates"] + ) + + +def test_knowledge_entry_create_requires_occurred_at_in_validation_schema() -> None: + """知识导入必须提供发生时间,且 OpenAPI 使用的 Schema 标记其为必填。""" + payload = { + "title": "缺少发生时间的会议纪要", + "source_type": "meeting_notes", + "content": "会议形成了明确结论。", + } + + with pytest.raises(ValidationError) as exc_info: + KnowledgeEntryCreate.model_validate(payload) + + assert any( + error["loc"] == ("occurred_at",) and error["type"] == "missing" + for error in exc_info.value.errors() + ) + assert "occurred_at" in KnowledgeEntryCreate.model_json_schema()["required"] + occurred_at = datetime(2026, 7, 8, 10, tzinfo=timezone.utc) + valid = KnowledgeEntryCreate.model_validate( + {**payload, "occurred_at": occurred_at.isoformat()} + ) + assert valid.occurred_at == occurred_at + + +def test_knowledge_entry_update_rejects_explicit_null_but_allows_omission( + db: Session, + project: Project, +) -> None: + """知识更新禁止显式清空发生时间,未传该字段时可更新其他内容。""" + occurred_at = datetime(2026, 7, 8, 10, tzinfo=timezone.utc) + entry = _knowledge_entry(project.id, "待更新知识", occurred_at) + db.add(entry) + db.commit() + db.refresh(entry) + + with pytest.raises(ValidationError) as exc_info: + KnowledgeEntryUpdate.model_validate({"occurred_at": None}) + + assert "资料实际发生时间不能为空" in str(exc_info.value) + update_data = KnowledgeEntryUpdate.model_validate({"title": "更新后的知识标题"}) + assert "occurred_at" not in update_data.model_fields_set + updated = project_service.update_knowledge_entry( + db, + entry.id, + update_data.model_dump(exclude_unset=True), + ) + assert updated.title == "更新后的知识标题" + assert updated.occurred_at == occurred_at + + +def test_knowledge_period_boundaries_follow_report_timezone( + db: Session, + manager: User, + project: Project, +) -> None: + """Asia/Shanghai 周期的首尾时刻应纳入,周期外一微秒应排除。""" + local_start_in_utc = datetime(2026, 7, 5, 16, 0, tzinfo=timezone.utc) + local_end_in_utc = datetime(2026, 7, 12, 15, 59, 59, 999999, tzinfo=timezone.utc) + entries = [ + _knowledge_entry( + project.id, + "周期开始边界", + local_start_in_utc, + ), + _knowledge_entry( + project.id, + "周期结束边界", + local_end_in_utc, + ), + _knowledge_entry( + project.id, + "周期开始前", + local_start_in_utc - timedelta(microseconds=1), + ), + _knowledge_entry( + project.id, + "周期结束后", + local_end_in_utc + timedelta(microseconds=1), + ), + ] + db.add_all(entries) + db.commit() + report = _create_report(db, project.id, manager) + + collected, coverage, _, _ = weekly_report_service.collect_report_inputs( + db, report.id, manager + ) + knowledge_titles = { + fact.evidence[0].source_title + for fact in collected.facts + if fact.source_key and fact.source_key.startswith("knowledge:") + } + + assert coverage["knowledge_count"] == 2 + assert knowledge_titles == {"周期开始边界", "周期结束边界"} + + +def test_knowledge_collection_limit_is_persisted_and_warned( + db: Session, + manager: User, + project: Project, +) -> None: + """101 条合格知识只采集 100 条,并完整保留截断审计信息。""" + occurred_at = datetime(2026, 7, 8, 10, tzinfo=timezone.utc) + db.add_all( + [ + _knowledge_entry(project.id, f"批量知识-{index:03d}", occurred_at) + for index in range(101) + ] + ) + db.commit() + report = _create_report(db, project.id, manager) + + collected, coverage, created_count, _ = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + persisted = weekly_report_service.get_report(db, report.id) + detail = weekly_report_service.serialize_report(persisted, include_detail=True) + validation = weekly_report_service.validate_report(db, report.id) + + assert coverage["knowledge_count"] == 100 + assert coverage["truncated_source_count"] == 1 + assert created_count == 100 + assert len( + [ + fact + for fact in collected.facts + if fact.source_key and fact.source_key.startswith("knowledge:") + ] + ) == 100 + assert json.loads(persisted.coverage_json)["truncated_source_count"] == 1 + assert detail["coverage"]["truncated_source_count"] == 1 + assert any( + "有 1 条知识资料因单次采集上限未纳入候选" in warning + for warning in validation["warnings"] + ) + + +def test_default_cutoff_includes_last_microsecond_of_local_week( + db: Session, + manager: User, + project: Project, +) -> None: + """默认截止时间必须覆盖项目时区周末最后一微秒。""" + local_end = datetime.combine( + WEEK_END, + datetime.max.time(), + tzinfo=ZoneInfo("Asia/Shanghai"), + ) + expected_cutoff = local_end.astimezone(timezone.utc) + db.add(_knowledge_entry(project.id, "周期最后一微秒", expected_cutoff)) + db.commit() + report = weekly_report_service.create_report( + db, + project.id, + manager, + WeeklyReportCreate( + week_start=WEEK_START, + week_end=WEEK_END, + timezone="Asia/Shanghai", + ), + ) + + _, coverage, _, _ = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + + assert report.cutoff_at == expected_cutoff + assert coverage["knowledge_count"] == 1 + + +def test_current_week_default_cutoff_is_bounded_by_current_time( + db: Session, + manager: User, + project: Project, +) -> None: + """当前周默认截止时间取当前时刻,不能落到本周未来时段。""" + zone = ZoneInfo("Asia/Shanghai") + before = datetime.now(timezone.utc) + local_today = before.astimezone(zone).date() + current_week_start = local_today - timedelta(days=local_today.weekday()) + current_week_end = current_week_start + timedelta(days=6) + report = weekly_report_service.create_report( + db, + project.id, + manager, + WeeklyReportCreate( + week_start=current_week_start, + week_end=current_week_end, + timezone="Asia/Shanghai", + ), + ) + after = datetime.now(timezone.utc) + period_end_at = datetime.combine( + current_week_end, + datetime.max.time(), + tzinfo=zone, + ).astimezone(timezone.utc) + + assert before <= report.cutoff_at <= after + assert report.cutoff_at <= period_end_at + + +def test_future_explicit_cutoff_blocks_validation_and_approval( + db: Session, + manager: User, + project: Project, +) -> None: + """显式未来截止时间必须成为批准阻断项,不能生成正式版本。""" + report = weekly_report_service.create_report( + db, + project.id, + manager, + WeeklyReportCreate( + week_start=WEEK_START, + week_end=WEEK_END, + timezone="Asia/Shanghai", + cutoff_at=datetime.now(timezone.utc) + timedelta(days=1), + ), + ) + fact = _add_verified_fact(db, report.id, manager) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# {report.title}\n\n{fact.statement}【事实#{fact.id}】", + ) + + validation = weekly_report_service.validate_report(db, report.id) + with pytest.raises(HTTPException) as exc_info: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=source_version.content_markdown, + overall_status="正常", + ), + ) + + assert validation["can_approve"] is False + assert "数据截止时间仍在未来,不能批准周报" in validation["blockers"] + assert exc_info.value.status_code == 409 + assert "数据截止时间仍在未来,不能批准周报" in exc_info.value.detail["blockers"] + assert weekly_report_service.get_report(db, report.id).status != "approved" + assert db.query(ProjectWeeklyReportVersion).count() == 1 + + +def test_validation_blocks_completed_risk_and_decision_gaps( + db: Session, + manager: User, + project: Project, +) -> None: + """完成、风险和决策事实缺少必需依据时必须阻断批准。""" + report = _create_report(db, project.id, manager) + _add_verified_fact( + db, + report.id, + manager, + statement="任务已经完成", + business_status="已完成", + ) + risk = _add_verified_fact( + db, + report.id, + manager, + category="risk", + statement="交付存在延期风险", + business_status="风险", + ) + decision = _add_verified_fact( + db, + report.id, + manager, + statement="需要管理层决定资源投入", + decision_required=True, + ) + + result = weekly_report_service.validate_report(db, report.id) + blockers = "\n".join(result["blockers"]) + + assert result["can_approve"] is False + assert "标记为已完成" in blockers + assert f"事实#{risk.id} 是风险事项,但缺少负责人" in blockers + assert f"事实#{risk.id} 是风险事项,但缺少下一步动作" in blockers + assert f"事实#{risk.id} 是风险事项,但缺少影响说明" in blockers + assert f"事实#{decision.id} 需要决策,但缺少决策人" in blockers + assert f"事实#{decision.id} 需要决策,但缺少决策截止日期" in blockers + assert f"事实#{decision.id} 需要决策,但缺少逾期影响说明" in blockers + + +def test_completed_fact_accepts_actual_completion_snapshot( + db: Session, + manager: User, + project: Project, +) -> None: + """带实际完成日期的已完成事实通过完成证据校验。""" + report = _create_report(db, project.id, manager) + _add_verified_fact( + db, + report.id, + manager, + statement="任务已在本周完成", + business_status="已完成", + current_value={"actual_end": WEEK_END.isoformat(), "progress_pct": 100}, + ) + + result = weekly_report_service.validate_report(db, report.id) + + assert result["can_approve"] is True + assert not any("标记为已完成" in item for item in result["blockers"]) + + +def test_manual_self_statement_is_not_completion_evidence( + db: Session, + manager: User, + project: Project, +) -> None: + """系统自动生成的人工自述证据不能自证事项已经完成。""" + report = _create_report(db, project.id, manager) + fact = weekly_report_service.create_manual_fact( + db, + report.id, + manager, + WeeklyReportFactCreate( + category="progress", + statement="开发任务已经完成", + business_status="已完成", + ), + ) + weekly_report_service.update_fact( + db, + fact.id, + manager, + {"review_status": "verified"}, + ) + + result = weekly_report_service.validate_report(db, report.id) + + assert result["can_approve"] is False + assert any("标记为已完成" in item for item in result["blockers"]) + + +def test_explicit_completion_evidence_title_allows_completed_fact( + db: Session, + manager: User, + project: Project, +) -> None: + """人工上传且明确标注为完成证据的材料可以支持已完成结论。""" + report = _create_report(db, project.id, manager) + fact = weekly_report_service.create_manual_fact( + db, + report.id, + manager, + WeeklyReportFactCreate( + category="progress", + statement="开发任务已经完成", + business_status="已完成", + evidence=[ + { + "source_type": "manual", + "source_title": "完成证据:客户验收邮件", + "source_excerpt": "客户已确认本次交付满足验收要求。", + } + ], + ), + ) + weekly_report_service.update_fact( + db, + fact.id, + manager, + {"review_status": "verified"}, + ) + + result = weekly_report_service.validate_report(db, report.id) + + assert result["can_approve"] is True + assert not any("标记为已完成" in item for item in result["blockers"]) + + +def test_source_hash_change_invalidates_previous_verification( + db: Session, + manager: User, + project: Project, +) -> None: + """结构化来源在核验后变化时必须阻断批准并要求重新采集。""" + plan = ProjectPlan( + project_id=project.id, + task_code="T-002", + phase_name="测试", + secondary_task="执行回归测试", + duration_days=3, + progress_pct=20, + status="进行中", + assignee="李四", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + db.add(plan) + db.commit() + report = _create_report(db, project.id, manager) + collected, _, _, _ = weekly_report_service.collect_report_inputs(db, report.id, manager) + fact = next( + item for item in collected.facts if item.source_key == f"plan:{plan.id}:progress" + ) + weekly_report_service.update_fact( + db, + fact.id, + manager, + {"review_status": "verified"}, + ) + + plan.progress_pct = 80 + db.commit() + result = weekly_report_service.validate_report(db, report.id) + + assert result["can_approve"] is False + assert any("引用的来源记录已更新,需要重新采集并核验" in item for item in result["blockers"]) + + +@pytest.mark.parametrize("changed_field", ["title", "occurred_at"]) +def test_knowledge_metadata_change_expires_frozen_fact_hash( + db: Session, + manager: User, + project: Project, + changed_field: str, +) -> None: + """知识标题或发生时间变化并重核后,旧草稿事实指纹必须失效。""" + knowledge = _knowledge_entry( + project.id, + "初始会议纪要", + datetime(2026, 7, 8, 10, tzinfo=timezone.utc), + ) + db.add(knowledge) + db.commit() + report = _create_report(db, project.id, manager) + collected, _, _, _ = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + fact = next( + item + for item in collected.facts + if item.source_key == f"knowledge:{knowledge.id}:candidate" + ) + weekly_report_service.update_fact( + db, + fact.id, + manager, + {"review_status": "verified"}, + ) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# 知识周报\n\n{fact.statement}【事实#{fact.id}】", + ) + old_hash = json.loads(source_version.fact_hashes_json)[str(fact.id)] + changed_values: dict[str, object] + if changed_field == "title": + changed_values = {"title": "变更后的会议纪要标题"} + else: + changed_values = { + "occurred_at": datetime(2026, 7, 9, 10, tzinfo=timezone.utc) + } + db.execute( + update(ProjectKnowledgeEntry) + .where(ProjectKnowledgeEntry.id == knowledge.id) + .values(**changed_values, updated_at=SOURCE_SNAPSHOT_AT) + ) + db.commit() + db.expire_all() + + recollected, _, _, updated_count = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + current_fact = next(item for item in recollected.facts if item.id == fact.id) + assert updated_count >= 1 + assert current_fact.review_status == "pending_confirmation" + current_fact = weekly_report_service.update_fact( + db, + fact.id, + manager, + {"review_status": "verified"}, + ) + current_hash = weekly_report_service._fact_snapshot_hash(current_fact) + + with pytest.raises(HTTPException) as exc_info: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=source_version.content_markdown, + overall_status="正常", + ), + ) + + assert current_hash != old_hash + assert exc_info.value.status_code == 409 + assert any( + f"事实#{fact.id}在草稿生成后已变化" in item + for item in exc_info.value.detail["blockers"] + ) + + +def test_critical_path_plan_metadata_change_expires_frozen_fact_hash( + db: Session, + manager: User, + project: Project, +) -> None: + """关键路径计划标题、负责人和结束时间变化后,旧草稿指纹必须失效。""" + plan = ProjectPlan( + project_id=project.id, + task_code="CP-HASH", + phase_name="关键路径阶段", + secondary_task="关键路径初始任务", + duration_days=3, + progress_pct=30, + status="进行中", + assignee="初始负责人", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + db.add(plan) + db.commit() + report = _create_report(db, project.id, manager) + collected, _, _, _ = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + fact = next( + item + for item in collected.facts + if item.source_key == f"critical_path:{plan.id}" + ) + fact = weekly_report_service.update_fact( + db, + fact.id, + manager, + {"category": "progress", "review_status": "verified"}, + ) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# 关键路径周报\n\n{fact.statement}【事实#{fact.id}】", + ) + old_hash = json.loads(source_version.fact_hashes_json)[str(fact.id)] + db.execute( + update(ProjectPlan) + .where(ProjectPlan.id == plan.id) + .values( + secondary_task="关键路径变更后任务", + assignee="变更后负责人", + planned_end=WEEK_END - timedelta(days=1), + updated_at=SOURCE_SNAPSHOT_AT, + ) + ) + db.commit() + db.expire_all() + + recollected, _, _, updated_count = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + current_fact = next(item for item in recollected.facts if item.id == fact.id) + assert updated_count >= 1 + assert current_fact.review_status == "needs_judgment" + current_fact = weekly_report_service.update_fact( + db, + fact.id, + manager, + {"category": "progress", "review_status": "verified"}, + ) + current_hash = weekly_report_service._fact_snapshot_hash(current_fact) + + with pytest.raises(HTTPException) as exc_info: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=source_version.content_markdown, + overall_status="正常", + ), + ) + + assert current_hash != old_hash + assert exc_info.value.status_code == 409 + assert any( + f"事实#{fact.id}在草稿生成后已变化" in item + for item in exc_info.value.detail["blockers"] + ) + + +def test_external_import_starts_pending_and_identical_retry_is_noop( + db: Session, + manager: User, + project: Project, +) -> None: + """首次外部导入待确认;完全相同的幂等重试不能撤销人工核验。""" + report = _create_report(db, project.id, manager) + external_fact = WeeklyReportExternalFactCreate( + external_id="codex-fact-001", + category="progress", + statement="Codex 导入的候选进展", + business_status="关注", + ) + + created, updated, fact_ids = weekly_report_service.batch_upsert_external_facts( + db, + project.id, + report.id, + [external_fact], + ) + assert (created, updated) == (1, 0) + fact = weekly_report_service.get_fact(db, fact_ids[0]) + assert fact.review_status == "pending_confirmation" + assert fact.verified_by is None + + weekly_report_service.update_fact( + db, + fact.id, + manager, + {"review_status": "verified"}, + ) + draft_report = weekly_report_service.get_report(db, report.id) + draft_report.status = "draft" + db.commit() + created, updated, fact_ids = weekly_report_service.batch_upsert_external_facts( + db, + project.id, + report.id, + [external_fact], + ) + fact = weekly_report_service.get_fact(db, fact_ids[0]) + + assert (created, updated) == (0, 0) + assert fact.review_status == "verified" + assert fact.verified_by == manager.id + assert fact.verified_at is not None + assert weekly_report_service.get_report(db, report.id).status == "draft" + + +def test_external_payload_change_updates_fields_and_returns_to_pending( + db: Session, + manager: User, + project: Project, +) -> None: + """相同 external_id 的语义字段变化必须更新记录并重新进入待确认。""" + report = _create_report(db, project.id, manager) + initial = WeeklyReportExternalFactCreate( + external_id="codex-fact-002", + category="risk", + statement="外部系统识别到交付风险", + business_status="风险", + owner="张三", + next_action="补充验证", + impact_description="可能影响交付时间", + ) + _, _, fact_ids = weekly_report_service.batch_upsert_external_facts( + db, + project.id, + report.id, + [initial], + ) + fact_id = fact_ids[0] + weekly_report_service.update_fact( + db, + fact_id, + manager, + {"review_status": "verified"}, + ) + changed = WeeklyReportExternalFactCreate( + external_id="codex-fact-002", + category="risk", + statement="外部系统识别到交付风险", + business_status="风险", + owner="李四", + next_action="当天升级处理", + impact_description="可能导致里程碑延期", + ) + + created, updated, fact_ids = weekly_report_service.batch_upsert_external_facts( + db, + project.id, + report.id, + [changed], + ) + fact = weekly_report_service.get_fact(db, fact_ids[0]) + + assert (created, updated) == (0, 1) + assert fact.id == fact_id + assert fact.owner == "李四" + assert fact.next_action == "当天升级处理" + assert fact.impact_description == "可能导致里程碑延期" + assert fact.review_status == "pending_confirmation" + assert fact.verified_by is None + assert fact.verified_at is None + + +def test_approved_final_requires_at_least_one_fact_reference( + db: Session, + manager: User, + project: Project, +) -> None: + """终稿正文即使源草稿有 fact_ids,也不能在删除全部引用后获批。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact(db, report.id, manager) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# 草稿\n\n{fact.statement}【事实#{fact.id}】", + ) + + with pytest.raises(HTTPException) as exc_info: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown="# 人工终稿\n\n正文已删除所有事实引用。", + overall_status="正常", + ), + ) + + assert exc_info.value.status_code == 409 + + +def test_human_draft_fact_ids_match_body_references( + db: Session, + manager: User, + project: Project, +) -> None: + """人工删去正文引用后,新版本 fact_ids 必须同步反映实际引用。""" + report = _create_report(db, project.id, manager) + first_fact = _add_verified_fact( + db, + report.id, + manager, + statement="第一条已核验事实", + ) + second_fact = _add_verified_fact( + db, + report.id, + manager, + statement="第二条已核验事实", + ) + source_version = _add_draft_version( + db, + report.id, + manager, + [first_fact.id, second_fact.id], + ( + f"# 草稿\n\n第一条【事实#{first_fact.id}】\n\n" + f"第二条【事实#{second_fact.id}】" + ), + ) + + saved = weekly_report_service.save_draft_version( + db, + source_version.id, + manager, + f"# 人工草稿\n\n仅保留第一条【事实#{first_fact.id}】", + ) + + assert json.loads(saved.fact_ids_json) == [first_fact.id] + + +def test_human_draft_inherits_old_hash_and_freezes_new_fact_hash( + db: Session, + manager: User, + project: Project, +) -> None: + """人工草稿继承旧引用指纹,并为新增已核验引用冻结当前指纹。""" + report = _create_report(db, project.id, manager) + inherited_fact = _add_verified_fact( + db, + report.id, + manager, + statement="源草稿已经引用的事实", + ) + added_fact = _add_verified_fact( + db, + report.id, + manager, + statement="人工修订新增引用的事实", + ) + source_version = _add_draft_version( + db, + report.id, + manager, + [inherited_fact.id], + f"# 源草稿\n\n旧事实【事实#{inherited_fact.id}】", + ) + source_hashes = json.loads(source_version.fact_hashes_json) + + weekly_report_service.update_fact( + db, + inherited_fact.id, + manager, + { + "statement": "源草稿引用的事实已经变化并重新核验", + "review_status": "verified", + }, + ) + current_inherited_fact = weekly_report_service.get_fact(db, inherited_fact.id) + current_added_fact = weekly_report_service.get_fact(db, added_fact.id) + current_inherited_hash = weekly_report_service._fact_snapshot_hash( + current_inherited_fact + ) + current_added_hash = weekly_report_service._fact_snapshot_hash(current_added_fact) + + saved = weekly_report_service.save_draft_version( + db, + source_version.id, + manager, + ( + f"# 人工修订\n\n旧事实【事实#{inherited_fact.id}】\n\n" + f"新增事实【事实#{added_fact.id}】" + ), + ) + saved_hashes = json.loads(saved.fact_hashes_json) + + assert saved_hashes[str(inherited_fact.id)] == source_hashes[str(inherited_fact.id)] + assert saved_hashes[str(inherited_fact.id)] != current_inherited_hash + assert saved_hashes[str(added_fact.id)] == current_added_hash + + +@pytest.mark.parametrize("operation", ["generate", "save", "approve"]) +def test_version_number_is_allocated_only_after_report_lock( + db: Session, + manager: User, + project: Project, + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + """生成、修订和批准都必须先锁周报,再分配下一个版本号。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact(db, report.id, manager) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# {report.title}\n\n{fact.statement}【事实#{fact.id}】", + ) + events: list[str] = [] + original_lock = weekly_report_service._lock_report_for_update + original_next = weekly_report_service._next_version_number + + def tracked_lock(session: Session, report_id: int) -> ProjectWeeklyReport: + """记录真实行锁调用。""" + events.append("lock") + return original_lock(session, report_id) + + def tracked_next(session: Session, report_id: int, audience_type: str) -> int: + """记录版本号分配调用。""" + events.append("next") + return original_next(session, report_id, audience_type) + + async def fake_completion( + *, + api_base_url: str, + api_key: str, + model: str, + messages: list[dict[str, Any]], + ) -> tuple[str, str]: + """返回确定性的合法模型响应。""" + del api_base_url, api_key, model, messages + return json.dumps(_generated_payload(fact.id), ensure_ascii=False), "lock-test-model" + + monkeypatch.setattr( + weekly_report_service, + "_lock_report_for_update", + tracked_lock, + ) + monkeypatch.setattr( + weekly_report_service, + "_next_version_number", + tracked_next, + ) + monkeypatch.setattr( + weekly_report_service.agent_service, + "request_plain_completion", + fake_completion, + ) + + if operation == "generate": + asyncio.run( + weekly_report_service.generate_report_draft( + db, + report.id, + manager, + requested_model="requested-test-model", + temporary_api_key="test-api-key", + ) + ) + elif operation == "save": + weekly_report_service.save_draft_version( + db, + source_version.id, + manager, + source_version.content_markdown, + ) + else: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=source_version.content_markdown, + overall_status="正常", + ), + ) + + assert events == ["lock", "next"] + + +def test_two_concurrent_draft_saves_get_distinct_version_numbers( + db: Session, + test_engine: Engine, + manager: User, + project: Project, +) -> None: + """两个并发人工保存由周报行锁串行化,不能分配重复版本号。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact(db, report.id, manager) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# {report.title}\n\n{fact.statement}【事实#{fact.id}】", + ) + report_id = report.id + source_version_id = source_version.id + manager_id = manager.id + fact_id = fact.id + db.commit() + barrier = Barrier(2) + outcomes: Queue[tuple[str, int | str]] = Queue() + session_factory = sessionmaker(bind=test_engine) + + def save_worker(label: str) -> None: + """在独立数据库会话中并发保存人工草稿。""" + session = session_factory() + try: + current_user = session.get(User, manager_id) + if current_user is None: + raise RuntimeError("并发保存找不到测试用户") + barrier.wait(timeout=5) + saved = weekly_report_service.save_draft_version( + session, + source_version_id, + current_user, + f"# 并发修订{label}\n\n事实【事实#{fact_id}】", + ) + outcomes.put(("ok", saved.version_no)) + except BaseException as exc: + session.rollback() + outcomes.put(("error", repr(exc))) + finally: + session.close() + + threads = [ + Thread(target=save_worker, args=(label,), daemon=True) + for label in ("A", "B") + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + assert not any(thread.is_alive() for thread in threads) + results = [outcomes.get(timeout=1) for _ in threads] + assert all(status == "ok" for status, _ in results), results + assert {value for _, value in results} == {2, 3} + db.expire_all() + assert [ + version.version_no + for version in ( + db.query(ProjectWeeklyReportVersion) + .filter(ProjectWeeklyReportVersion.report_id == report_id) + .order_by(ProjectWeeklyReportVersion.version_no.asc()) + .all() + ) + ] == [1, 2, 3] + + +def test_waiting_fact_update_refetches_approved_status_after_report_lock( + db: Session, + test_engine: Engine, + manager: User, + project: Project, +) -> None: + """等待行锁的事实更新必须看到已批准状态并返回409,不能写入旧状态。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact( + db, + report.id, + manager, + statement="并发批准前的事实原文", + ) + report_id = report.id + fact_id = fact.id + manager_id = manager.id + original_statement = fact.statement + db.commit() + session_factory = sessionmaker(bind=test_engine) + locker_session = session_factory() + pid_queue: Queue[int] = Queue() + outcome_queue: Queue[tuple[int, str]] = Queue() + + def update_worker() -> None: + """在独立会话中尝试更新即将批准的周报事实。""" + session = session_factory() + try: + pid = session.execute(text("SELECT pg_backend_pid()")).scalar_one() + pid_queue.put(int(pid)) + current_user = session.get(User, manager_id) + if current_user is None: + raise RuntimeError("并发更新找不到测试用户") + weekly_report_service.update_fact( + session, + fact_id, + current_user, + {"statement": "不应写入的并发修改"}, + ) + outcome_queue.put((200, "unexpected success")) + except HTTPException as exc: + session.rollback() + outcome_queue.put((exc.status_code, str(exc.detail))) + except BaseException as exc: + session.rollback() + outcome_queue.put((500, repr(exc))) + finally: + session.close() + + thread = Thread(target=update_worker, daemon=True) + saw_lock_wait = False + try: + locked_report = weekly_report_service._lock_report_for_update( + locker_session, + report_id, + ) + thread.start() + waiter_pid = pid_queue.get(timeout=3) + deadline = time.monotonic() + 3 + with test_engine.connect() as observer: + while time.monotonic() < deadline: + wait_type = observer.execute( + text( + "SELECT wait_event_type FROM pg_stat_activity WHERE pid = :pid" + ), + {"pid": waiter_pid}, + ).scalar_one_or_none() + if wait_type == "Lock": + saw_lock_wait = True + break + time.sleep(0.02) + locked_report.status = "approved" + locked_report.approved_by = manager_id + locked_report.approved_at = datetime.now(timezone.utc) + locker_session.commit() + thread.join(timeout=10) + finally: + if locker_session.in_transaction(): + locker_session.rollback() + locker_session.close() + + assert saw_lock_wait is True + assert thread.is_alive() is False + status_code, detail = outcome_queue.get(timeout=1) + assert status_code == 409, detail + assert "不可继续修改" in detail + db.expire_all() + current_report = weekly_report_service.get_report(db, report_id) + current_fact = weekly_report_service.get_fact(db, fact_id) + assert current_report.status == "approved" + assert current_fact.statement == original_statement + archived = weekly_report_service.archive_report(db, report_id, manager) + assert archived.status == "archived" + + +def test_ai_draft_uses_only_verified_facts_and_persists_audit_fields( + db: Session, + manager: User, + project: Project, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """合法模型响应只接收已核验事实,并保存原文、模型和事实引用。""" + report = _create_report(db, project.id, manager) + verified_fact = _add_verified_fact( + db, + report.id, + manager, + statement="已核验的项目进展", + ) + pending_fact = weekly_report_service.create_manual_fact( + db, + report.id, + manager, + WeeklyReportFactCreate( + category="progress", + statement="尚未核验的候选进展", + ), + ) + raw_output = json.dumps( + _generated_payload(verified_fact.id), + ensure_ascii=False, + ) + captured_messages: list[dict[str, Any]] = [] + + async def fake_completion( + *, + api_base_url: str, + api_key: str, + model: str, + messages: list[dict[str, Any]], + ) -> tuple[str, str]: + """返回确定性的合法周报 JSON,并记录模型输入。""" + del api_base_url, api_key, model + captured_messages.extend(messages) + return raw_output, "normalized-test-model" + + monkeypatch.setattr( + weekly_report_service.agent_service, + "request_plain_completion", + fake_completion, + ) + + version = asyncio.run( + weekly_report_service.generate_report_draft( + db, + report.id, + manager, + requested_model="requested-test-model", + temporary_api_key="test-api-key", + ) + ) + model_input = json.loads(captured_messages[1]["content"]) + input_fact_ids = { + item["fact_id"] for item in model_input["verified_facts"] + } + + assert input_fact_ids == {verified_fact.id} + assert pending_fact.id not in input_fact_ids + assert version.version_stage == "ai_draft" + assert version.raw_output == raw_output + assert version.model_name == "normalized-test-model" + assert json.loads(version.fact_ids_json) == [verified_fact.id] + assert json.loads(version.fact_hashes_json) == { + str(verified_fact.id): weekly_report_service._fact_snapshot_hash( + weekly_report_service.get_fact(db, verified_fact.id) + ) + } + assert version.content_markdown.startswith(f"# {report.title}\n") + assert not version.content_markdown.startswith("# 自动生成周报\n") + assert f"【事实#{verified_fact.id}】" in version.content_markdown + assert weekly_report_service.get_report(db, report.id).status == "draft" + + +def test_changed_fact_blocks_old_draft_but_regenerated_draft_can_be_approved( + db: Session, + manager: User, + project: Project, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """事实重核后旧指纹不能批准,重新生成的最新指纹可以批准。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact( + db, + report.id, + manager, + statement="初次核验的事实内容", + ) + + async def fake_completion( + *, + api_base_url: str, + api_key: str, + model: str, + messages: list[dict[str, Any]], + ) -> tuple[str, str]: + """按当前事实返回确定性的合法模型响应。""" + del api_base_url, api_key, model, messages + return ( + json.dumps(_generated_payload(fact.id), ensure_ascii=False), + "normalized-test-model", + ) + + monkeypatch.setattr( + weekly_report_service.agent_service, + "request_plain_completion", + fake_completion, + ) + old_version = asyncio.run( + weekly_report_service.generate_report_draft( + db, + report.id, + manager, + requested_model="requested-test-model", + temporary_api_key="test-api-key", + ) + ) + old_hash = json.loads(old_version.fact_hashes_json)[str(fact.id)] + + weekly_report_service.update_fact( + db, + fact.id, + manager, + { + "statement": "事实内容变化后已经重新核验", + "review_status": "verified", + }, + ) + + with pytest.raises(HTTPException) as old_approval_error: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=old_version.id, + content_markdown=old_version.content_markdown, + overall_status="正常", + ), + ) + + assert old_approval_error.value.status_code == 409 + assert any( + f"事实#{fact.id}在草稿生成后已变化" in item + for item in old_approval_error.value.detail["blockers"] + ) + + new_version = asyncio.run( + weekly_report_service.generate_report_draft( + db, + report.id, + manager, + requested_model="requested-test-model", + temporary_api_key="test-api-key", + ) + ) + new_hash = json.loads(new_version.fact_hashes_json)[str(fact.id)] + approved = weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=new_version.id, + content_markdown=new_version.content_markdown, + overall_status="正常", + ), + ) + + assert new_hash != old_hash + assert approved.status == "approved" + + +@pytest.mark.parametrize( + "failure_case", + [ + "invalid_json", + "unknown_fact_id", + "summary_without_reference", + "invented_iso_date", + "invented_chinese_date", + "forecast_upgraded_to_commitment", + "unknown_section", + "duplicate_section", + ], +) +def test_invalid_ai_output_is_rejected_without_side_effects( + db: Session, + manager: User, + project: Project, + monkeypatch: pytest.MonkeyPatch, + failure_case: str, +) -> None: + """模型结构、引用和日期语义异常均返回 502,且不写版本或状态。""" + report = _create_report(db, project.id, manager) + is_forecast_case = failure_case == "forecast_upgraded_to_commitment" + verified_fact = _add_verified_fact( + db, + report.id, + manager, + statement="预测交付日期需继续确认" if is_forecast_case else "已核验的项目进展", + date_type="forecast" if is_forecast_case else None, + date_value=WEEK_END if is_forecast_case else None, + ) + payload = _generated_payload(verified_fact.id) + + if failure_case == "invalid_json": + raw_output = "这不是合法 JSON" + else: + if failure_case == "unknown_fact_id": + payload["summary_fact_ids"] = [999999] + elif failure_case == "summary_without_reference": + payload["summary_fact_ids"] = [] + elif failure_case == "invented_iso_date": + payload["summary"] = "预计于 2026-08-01 完成交付" + elif failure_case == "invented_chinese_date": + payload["summary"] = "预计于 2026年8月1日 完成交付" + elif failure_case == "forecast_upgraded_to_commitment": + payload["summary"] = f"已承诺于 {WEEK_END.isoformat()} 完成交付" + elif failure_case == "unknown_section": + payload["sections"][0]["heading"] = "模型自创章节" + elif failure_case == "duplicate_section": + payload["sections"].append(dict(payload["sections"][0])) + raw_output = json.dumps(payload, ensure_ascii=False) + + async def fake_completion( + *, + api_base_url: str, + api_key: str, + model: str, + messages: list[dict[str, Any]], + ) -> tuple[str, str]: + """为异常场景返回指定模型原文。""" + del api_base_url, api_key, model, messages + return raw_output, "normalized-test-model" + + monkeypatch.setattr( + weekly_report_service.agent_service, + "request_plain_completion", + fake_completion, + ) + initial_status = weekly_report_service.get_report(db, report.id).status + initial_version_count = db.query(ProjectWeeklyReportVersion).count() + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + weekly_report_service.generate_report_draft( + db, + report.id, + manager, + requested_model="requested-test-model", + temporary_api_key="test-api-key", + ) + ) + + db.expire_all() + assert exc_info.value.status_code == 502 + assert db.query(ProjectWeeklyReportVersion).count() == initial_version_count + assert weekly_report_service.get_report(db, report.id).status == initial_status + + +def test_untrusted_evidence_cannot_spoof_structured_sources_or_hashes( + db: Session, + manager: User, + project: Project, +) -> None: + """人工和外部输入不能伪装结构化来源或自报内容哈希。""" + other_project = Project(name="证据伪装来源项目") + db.add(other_project) + db.flush() + other_plan = ProjectPlan( + project_id=other_project.id, + task_code="OTHER-PLAN", + phase_name="外部项目阶段", + secondary_task="不应被当前项目引用", + duration_days=1, + progress_pct=10, + status="进行中", + ) + other_knowledge = _knowledge_entry( + other_project.id, + "外部项目会议纪要", + datetime(2026, 7, 8, 10, tzinfo=timezone.utc), + ) + db.add_all([other_plan, other_knowledge]) + db.commit() + report = _create_report(db, project.id, manager) + + forged_manual_hash = "0" * 64 + manual_fact = weekly_report_service.create_manual_fact( + db, + report.id, + manager, + WeeklyReportFactCreate( + category="progress", + statement="人工输入尝试伪装计划来源", + evidence=[ + { + "source_type": "plan", + "source_id": str(other_plan.id), + "source_title": "伪装计划证据", + "source_excerpt": "客户端声称来自另一个项目的计划。", + "source_content_hash": forged_manual_hash, + } + ], + ), + ) + manual_evidence = manual_fact.evidence[0] + + forged_external_hash = "f" * 64 + _, _, external_fact_ids = weekly_report_service.batch_upsert_external_facts( + db, + project.id, + report.id, + [ + WeeklyReportExternalFactCreate( + external_id="codex-forged-source", + category="progress", + statement="外部输入尝试伪装知识来源", + evidence=[ + { + "source_type": "knowledge", + "source_id": str(other_knowledge.id), + "source_title": "伪装知识证据", + "source_excerpt": "客户端声称来自另一个项目的知识条目。", + "source_content_hash": forged_external_hash, + } + ], + ) + ], + ) + external_fact = weekly_report_service.get_fact(db, external_fact_ids[0]) + external_evidence = external_fact.evidence[0] + + assert manual_evidence.source_type == "manual" + assert manual_evidence.source_id is None + assert manual_evidence.source_content_hash != forged_manual_hash + assert len(manual_evidence.source_content_hash or "") == 64 + assert external_evidence.source_type == "external" + assert external_evidence.source_id == "codex-forged-source" + assert external_evidence.source_content_hash != forged_external_hash + assert len(external_evidence.source_content_hash or "") == 64 + + +def test_structured_evidence_from_other_project_or_without_hash_is_blocked( + db: Session, + manager: User, + project: Project, +) -> None: + """结构化证据跨项目或缺少快照哈希时不能进入可信草稿。""" + other_project = Project(name="结构化证据外部项目") + db.add(other_project) + db.flush() + same_project_plan = ProjectPlan( + project_id=project.id, + task_code="LOCAL-PLAN", + phase_name="当前项目阶段", + secondary_task="当前项目计划", + duration_days=1, + progress_pct=20, + status="进行中", + ) + other_project_plan = ProjectPlan( + project_id=other_project.id, + task_code="CROSS-PLAN", + phase_name="其他项目阶段", + secondary_task="跨项目计划", + duration_days=1, + progress_pct=30, + status="进行中", + ) + db.add_all([same_project_plan, other_project_plan]) + db.flush() + report = _create_report(db, project.id, manager) + + cross_project_fact = ProjectWeeklyReportFact( + report_id=report.id, + category="progress", + statement="引用了其他项目结构化来源", + business_status="正常", + review_status="verified", + audience_scope="internal", + source_mode="structured", + created_by=manager.id, + verified_by=manager.id, + verified_at=datetime.now(timezone.utc), + ) + missing_hash_fact = ProjectWeeklyReportFact( + report_id=report.id, + category="progress", + statement="结构化来源缺少快照哈希", + business_status="正常", + review_status="verified", + audience_scope="internal", + source_mode="structured", + created_by=manager.id, + verified_by=manager.id, + verified_at=datetime.now(timezone.utc), + ) + db.add_all([cross_project_fact, missing_hash_fact]) + db.flush() + db.add_all( + [ + ProjectWeeklyReportEvidence( + fact_id=cross_project_fact.id, + source_type="plan", + source_id=str(other_project_plan.id), + source_title="跨项目计划", + source_excerpt="不属于当前周报项目。", + source_content_hash="a" * 64, + ), + ProjectWeeklyReportEvidence( + fact_id=missing_hash_fact.id, + source_type="plan", + source_id=str(same_project_plan.id), + source_title="缺少哈希的当前项目计划", + source_excerpt="来源存在但未冻结哈希。", + source_content_hash=None, + ), + ] + ) + db.commit() + + result = weekly_report_service.validate_report(db, report.id) + + assert result["can_approve"] is False + assert any( + f"事实#{cross_project_fact.id} 引用的来源记录已经不存在" in item + for item in result["blockers"] + ) + assert any( + f"事实#{missing_hash_fact.id} 结构化来源缺少内容哈希" in item + for item in result["blockers"] + ) + + +def test_stale_source_blocks_generation_before_llm_call( + db: Session, + manager: User, + project: Project, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """来源变化后生成草稿应在调用模型前返回冲突。""" + plan = ProjectPlan( + project_id=project.id, + task_code="STALE-PLAN", + phase_name="测试", + secondary_task="验证生成前证据校验", + duration_days=2, + progress_pct=20, + status="进行中", + assignee="测试负责人", + planned_start=WEEK_START, + planned_end=WEEK_END, + created_at=SOURCE_SNAPSHOT_AT, + updated_at=SOURCE_SNAPSHOT_AT, + ) + db.add(plan) + db.commit() + report = _create_report(db, project.id, manager) + collected, _, _, _ = weekly_report_service.collect_report_inputs( + db, + report.id, + manager, + ) + fact = next( + item for item in collected.facts if item.source_key == f"plan:{plan.id}:progress" + ) + weekly_report_service.update_fact( + db, + fact.id, + manager, + {"review_status": "verified"}, + ) + plan.progress_pct = 80 + db.commit() + llm_call_count = 0 + + async def unexpected_completion( + *, + api_base_url: str, + api_key: str, + model: str, + messages: list[dict[str, Any]], + ) -> tuple[str, str]: + """记录任何不应发生的模型调用。""" + nonlocal llm_call_count + del api_base_url, api_key, model, messages + llm_call_count += 1 + raise AssertionError("证据失效时不应调用大模型") + + monkeypatch.setattr( + weekly_report_service.agent_service, + "request_plain_completion", + unexpected_completion, + ) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + weekly_report_service.generate_report_draft( + db, + report.id, + manager, + requested_model="requested-test-model", + temporary_api_key="test-api-key", + ) + ) + + assert exc_info.value.status_code == 409 + assert llm_call_count == 0 + assert db.query(ProjectWeeklyReportVersion).count() == 0 + + +def test_approval_blocks_uncited_ordinary_content_line( + db: Session, + manager: User, + project: Project, +) -> None: + """终稿即使已有合法引用,也不能夹带没有事实引用的普通正文。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact(db, report.id, manager) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# 草稿\n\n{fact.statement}【事实#{fact.id}】", + ) + + with pytest.raises(HTTPException) as exc_info: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=( + f"# 人工终稿\n\n{fact.statement}【事实#{fact.id}】\n\n" + "这是一条没有任何事实引用的普通正文。" + ), + overall_status="正常", + ), + ) + + assert exc_info.value.status_code == 409 + assert any( + "未引用事实的正文" in item + for item in exc_info.value.detail["blockers"] + ) + + +def test_approval_allows_standard_headings_and_cited_factual_heading( + db: Session, + manager: User, + project: Project, +) -> None: + """固定结构标题可无引用,事实性标题带有效事实引用后可以批准。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact( + db, + report.id, + manager, + statement="客户验收结论已经核验", + ) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# 草稿\n\n{fact.statement}【事实#{fact.id}】", + ) + final_content = ( + f"# {report.title}\n\n" + "## 总体概述\n\n" + f"{fact.statement}【事实#{fact.id}】\n\n" + "## 本周重点\n\n" + "- 本期无已核验事项。\n\n" + f"## 客户验收结论【事实#{fact.id}】\n\n" + f"- {fact.statement}【事实#{fact.id}】" + ) + + approved = weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=final_content, + overall_status="正常", + ), + ) + + assert approved.status == "approved" + + +def test_approval_blocks_uncited_factual_heading( + db: Session, + manager: User, + project: Project, +) -> None: + """不在结构白名单中的事实性标题没有引用时必须阻断批准。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact( + db, + report.id, + manager, + statement="客户验收结论已经核验", + ) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# 草稿\n\n{fact.statement}【事实#{fact.id}】", + ) + final_content = ( + f"# {report.title}\n\n" + "## 总体概述\n\n" + f"{fact.statement}【事实#{fact.id}】\n\n" + "## 客户验收已经完成\n\n" + f"{fact.statement}【事实#{fact.id}】" + ) + + with pytest.raises(HTTPException) as exc_info: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=final_content, + overall_status="正常", + ), + ) + + assert exc_info.value.status_code == 409 + assert any( + "第 [7] 行包含未引用事实的正文" in item + for item in exc_info.value.detail["blockers"] + ) + + +def test_approval_blocks_uncited_table_data_row_but_ignores_separator( + db: Session, + manager: User, + project: Project, +) -> None: + """Markdown 表格数据行必须引用事实,纯分隔行不应被误判。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact(db, report.id, manager) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# 草稿\n\n{fact.statement}【事实#{fact.id}】", + ) + final_content = ( + f"# {report.title}\n\n" + f"| 项目【事实#{fact.id}】 | 状态 |\n" + "| --- | :---: |\n" + f"| 已核验事项 | 正常【事实#{fact.id}】 |\n" + "| 未引用事项 | 风险 |" + ) + + assert weekly_report_service._find_uncited_content_lines( + final_content, + {report.title}, + ) == [6] + with pytest.raises(HTTPException) as exc_info: + weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=final_content, + overall_status="正常", + ), + ) + + assert exc_info.value.status_code == 409 + assert any( + "第 [6] 行包含未引用事实的正文" in item + for item in exc_info.value.detail["blockers"] + ) + + +def test_approved_and_archived_reports_are_immutable( + db: Session, + manager: User, + project: Project, +) -> None: + """批准后和归档后均不能继续修改事实、采集来源或覆盖草稿。""" + report = _create_report(db, project.id, manager) + fact = _add_verified_fact(db, report.id, manager) + source_version = _add_draft_version( + db, + report.id, + manager, + [fact.id], + f"# {report.title}\n\n{fact.statement}【事实#{fact.id}】", + ) + + approved = weekly_report_service.approve_report( + db, + report.id, + manager, + WeeklyReportApproveRequest( + source_version_id=source_version.id, + content_markdown=source_version.content_markdown, + overall_status="正常", + ), + ) + assert approved.status == "approved" + + with pytest.raises(HTTPException) as approved_update_error: + weekly_report_service.update_fact( + db, + fact.id, + manager, + {"statement": "试图覆盖批准后的事实"}, + ) + assert approved_update_error.value.status_code == 409 + + with pytest.raises(HTTPException) as approved_draft_error: + weekly_report_service.save_draft_version( + db, + source_version.id, + manager, + "试图覆盖批准后的草稿", + ) + assert approved_draft_error.value.status_code == 409 + + archived = weekly_report_service.archive_report(db, report.id, manager) + assert archived.status == "archived" + + with pytest.raises(HTTPException) as archived_fact_error: + weekly_report_service.create_manual_fact( + db, + report.id, + manager, + WeeklyReportFactCreate( + category="progress", + statement="试图向归档周报追加事实", + ), + ) + assert archived_fact_error.value.status_code == 409 + + with pytest.raises(HTTPException) as archived_collect_error: + weekly_report_service.collect_report_inputs(db, report.id, manager) + assert archived_collect_error.value.status_code == 409 diff --git a/backend/tests/test_wiki_service.py b/backend/tests/test_wiki_service.py new file mode 100644 index 0000000..e19f81c --- /dev/null +++ b/backend/tests/test_wiki_service.py @@ -0,0 +1,359 @@ +"""TinyPM LLM Wiki 来源入箱、编译和导出测试。""" + +import asyncio +from datetime import datetime, timezone +from io import BytesIO +import json +from pathlib import Path +from typing import Any +from zipfile import ZipFile + +import pytest +from openpyxl import Workbook +from sqlalchemy.orm import Session + +from app.config import settings +from app.models.agent import AgentSession, AgentSetting +from app.models.project import Project, ProjectDocumentFile +from app.models.user import User +from app.models.wiki import ( + ProjectWikiPage, + ProjectWikiPageSource, + ProjectWikiSource, +) +from app.services import agent as agent_service +from app.services import project as project_service +from app.services import wiki as wiki_service + + +SOURCE_OCCURRED_AT = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) + + +def test_knowledge_updates_create_immutable_source_versions( + db: Session, + manager: User, + project: Project, +) -> None: + """知识正文变化应生成新来源并淘汰尚未编译的旧快照。""" + entry, created = project_service.upsert_knowledge_entry( + db, + project.id, + title="OTA 回滚策略", + source_type="development_detail", + content="首版采用单分区回滚策略。", + external_id="wiki-version-test", + tags=["OTA", "回滚"], + occurred_at=SOURCE_OCCURRED_AT, + author_user_id=manager.id, + ) + + assert created is True + first_source = db.query(ProjectWikiSource).one() + assert first_source.status == "inbox" + assert first_source.origin_ref == f"knowledge-entry:{entry.id}" + + project_service.update_knowledge_entry( + db, + entry.id, + {"content": "量产版改为 A/B 分区回滚策略。"}, + author_user_id=manager.id, + ) + + sources = db.query(ProjectWikiSource).order_by(ProjectWikiSource.id.asc()).all() + assert len(sources) == 2 + assert sources[0].status == "superseded" + assert sources[1].status == "inbox" + assert sources[0].snapshot_hash != sources[1].snapshot_hash + + project_service.update_knowledge_entry( + db, + entry.id, + {"content": "量产版改为 A/B 分区回滚策略。"}, + author_user_id=manager.id, + ) + assert db.query(ProjectWikiSource).count() == 2 + + +def test_project_chat_round_is_snapshotted_at_message_time( + db: Session, + manager: User, + project: Project, +) -> None: + """成功聊天按当时项目入箱,后续切换项目不会改变旧来源归属。""" + session = AgentSession( + user_id=manager.id, + project_id=project.id, + title="回滚方案讨论", + skill_id="general", + system_prompt="测试", + ) + db.add(session) + db.commit() + db.refresh(session) + + agent_service._persist_round( + db=db, + session=session, + user=manager, + user_message="本项目是否确定采用 A/B 分区?", + assistant_message="当前聊天只能说明讨论过该方案,仍需查项目证据。", + model="test-model", + tool_summaries=[], + is_error=False, + ) + source = db.query(ProjectWikiSource).one() + assert source.project_id == project.id + assert source.origin_kind == "agent_chat" + assert "可信类别:AI 生成内容" in source.raw_content + + another_project = Project(name="另一个项目") + db.add(another_project) + db.flush() + session.project_id = another_project.id + db.commit() + + agent_service._persist_round( + db=db, + session=session, + user=manager, + user_message="这是一条失败请求", + assistant_message="模型错误", + model="test-model", + tool_summaries=[], + is_error=True, + ) + db.refresh(source) + assert source.project_id == project.id + assert db.query(ProjectWikiSource).count() == 1 + + +def test_text_document_enters_wiki_inbox( + db: Session, + manager: User, + project: Project, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """后端应从文本项目文档提取原文,而不是依赖浏览器预览。""" + monkeypatch.setattr(settings, "DOCUMENTS_DIR", str(tmp_path)) + document_path = tmp_path / "files" / "project-test" / "design.md" + document_path.parent.mkdir(parents=True) + document_path.write_text("# OTA 设计\n\n采用双分区升级。", encoding="utf-8") + document = ProjectDocumentFile( + project_id=project.id, + file_name="design.md", + original_name="OTA设计.md", + file_path="files/project-test/design.md", + content_type="text/markdown", + file_size=document_path.stat().st_size, + ) + db.add(document) + db.flush() + + source, created = wiki_service.ingest_project_document( + db, + document, + author_user_id=manager.id, + ) + db.commit() + + assert created is True + assert source.status == "inbox" + assert source.source_type == "document" + assert "双分区升级" in source.raw_content + + +def test_xlsm_document_enters_wiki_inbox( + db: Session, + manager: User, + project: Project, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """宏工作簿应复用受限 Excel 文本提取,不执行其中的宏。""" + monkeypatch.setattr(settings, "DOCUMENTS_DIR", str(tmp_path)) + document_path = tmp_path / "files" / "project-test" / "matrix.xlsm" + document_path.parent.mkdir(parents=True) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "通信矩阵" + worksheet.append(["信号", "用途"]) + worksheet.append(["OTA_Mode", "升级模式控制"]) + workbook.save(document_path) + workbook.close() + document = ProjectDocumentFile( + project_id=project.id, + file_name="matrix.xlsm", + original_name="OTA通信矩阵.xlsm", + file_path="files/project-test/matrix.xlsm", + content_type="application/vnd.ms-excel.sheet.macroenabled.12", + file_size=document_path.stat().st_size, + ) + db.add(document) + db.flush() + + source, created = wiki_service.ingest_project_document( + db, + document, + author_user_id=manager.id, + ) + db.commit() + + assert created is True + assert source.status == "inbox" + assert "工作表:通信矩阵" in source.raw_content + assert "OTA_Mode | 升级模式控制" in source.raw_content + + +def test_compile_creates_traced_page_and_obsidian_export( + db: Session, + manager: User, + project: Project, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """结构化模型输出应原子生成页面、引用关系和可打开的 Obsidian ZIP。""" + monkeypatch.setattr(settings, "DOCUMENTS_DIR", str(tmp_path)) + entry, _ = project_service.upsert_knowledge_entry( + db, + project.id, + title="A/B 分区升级决策", + source_type="meeting_notes", + content="会议决定量产版本采用 A/B 分区,并补充掉电恢复测试。", + occurred_at=SOURCE_OCCURRED_AT, + author_user_id=manager.id, + ) + source = db.query(ProjectWikiSource).one() + db.add( + AgentSetting( + user_id=manager.id, + model="test-model", + api_key="test-key", + api_base_url="https://example.invalid/v1", + ) + ) + db.commit() + + async def fake_completion(**kwargs: Any) -> tuple[str, str]: + """返回引用本次动态来源ID的合法 Wiki 编译结果。""" + user_payload = json.loads(kwargs["messages"][1]["content"]) + source_id = user_payload["source"]["source_id"] + payload = { + "should_compile": True, + "source_note_markdown": "## 来源观点\n\n- 会议决定采用 A/B 分区。\n\n## 待验证问题\n\n- 掉电测试覆盖率待确认。", + "pages": [ + { + "slug": "ota-ab-partition", + "page_type": "topic", + "title": "OTA A/B 分区策略", + "summary": "记录项目 A/B 分区升级决策与待验证测试。", + "content_markdown": ( + "# OTA A/B 分区策略\n\n" + "## 来源观点\n\n- 项目会议决定采用 A/B 分区。\n\n" + "## AI 推断\n\n- 需要完善异常恢复设计。\n\n" + "## 待验证问题\n\n- 掉电覆盖率待确认。\n\n" + f"## 相关来源\n\n- [[{source_id}]]" + ), + "relation_type": "source_claim", + } + ], + } + return json.dumps(payload, ensure_ascii=False), kwargs["model"] + + monkeypatch.setattr(agent_service, "request_plain_completion", fake_completion) + run = asyncio.run( + wiki_service.compile_project_inbox( + db, + project.id, + manager, + trigger="test", + ) + ) + + assert run.status == "completed" + assert run.page_count == 1 + db.refresh(source) + assert source.status == "compiled" + page = db.query(ProjectWikiPage).one() + assert page.slug == "ota-ab-partition" + assert f"[[{source.source_id}]]" in page.content_markdown + relation = db.query(ProjectWikiPageSource).one() + assert relation.page_id == page.id + assert relation.source_id == source.id + + archive = wiki_service.build_project_vault_zip(db, project.id) + with ZipFile(BytesIO(archive)) as zip_file: + names = set(zip_file.namelist()) + assert "wiki/index.md" in names + assert "wiki/current-status.md" in names + assert "wiki/topics/ota-ab-partition.md" in names + assert f"wiki/sources/{source.source_id}.md" in names + assert "manifests/raw_sources.csv" in names + + assert entry.id is not None + + +def test_unknown_wiki_link_is_downgraded_to_plain_text( + db: Session, + manager: User, + project: Project, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """模型生成的未知双向链接不得污染最终 Obsidian 投影。""" + monkeypatch.setattr(settings, "DOCUMENTS_DIR", str(tmp_path)) + project_service.upsert_knowledge_entry( + db, + project.id, + title="链接检查来源", + source_type="other", + content="用于检查 Wiki 链接。", + occurred_at=SOURCE_OCCURRED_AT, + author_user_id=manager.id, + ) + db.add( + AgentSetting( + user_id=manager.id, + model="test-model", + api_key="test-key", + api_base_url="https://example.invalid/v1", + ) + ) + db.commit() + + async def fake_completion(**kwargs: Any) -> tuple[str, str]: + """返回包含未知 Wiki 目标的页面。""" + source_id = json.loads(kwargs["messages"][1]["content"])["source"]["source_id"] + return json.dumps( + { + "should_compile": True, + "source_note_markdown": "## 来源观点\n\n- 链接检查。", + "pages": [ + { + "slug": "link-check", + "page_type": "concept", + "title": "链接检查", + "summary": "检查未知链接处理。", + "content_markdown": f"# 链接检查\n\n[[ghost-page|不存在页面]]\n\n[[{source_id}]]", + "relation_type": "source_claim", + } + ], + }, + ensure_ascii=False, + ), kwargs["model"] + + monkeypatch.setattr(agent_service, "request_plain_completion", fake_completion) + run = asyncio.run( + wiki_service.compile_project_inbox( + db, + project.id, + manager, + trigger="test", + ) + ) + page = db.query(ProjectWikiPage).one() + + assert run.status == "completed" + assert "[[ghost-page" not in page.content_markdown + assert "不存在页面" in page.content_markdown + assert wiki_service.wiki_vault.lint_project_wiki(db, project.id) == [] diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..e9ed6c6 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,32 @@ +name: tinypm-weekly-test + +services: + test-db: + image: postgres:16 + environment: + POSTGRES_USER: tinypm_test + POSTGRES_PASSWORD: tinypm_test_password + POSTGRES_DB: tinypm_weekly_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tinypm_test -d tinypm_weekly_test"] + interval: 2s + timeout: 5s + retries: 15 + tmpfs: + - /var/lib/postgresql/data + + backend-test: + build: ./backend + environment: + DATABASE_URL: postgresql://tinypm_test:tinypm_test_password@test-db:5432/tinypm_weekly_test + TEST_DATABASE_URL: postgresql://tinypm_test:tinypm_test_password@test-db:5432/tinypm_weekly_test + PYTHONPATH: /app + depends_on: + test-db: + condition: service_healthy + volumes: + - ./backend/tests:/app/tests:ro + command: + - sh + - -c + - pip install --no-cache-dir -r requirements-dev.txt && pytest -q diff --git a/docker-compose.yml b/docker-compose.yml index cf48795..ac31477 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: build: ./backend restart: unless-stopped ports: - - "6100:6000" + - "${BACKEND_PORT:-6100}:6000" environment: DATABASE_URL: postgresql://tinypm:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/tinypm JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} @@ -28,8 +28,17 @@ services: INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin} INITIAL_ADMIN_DISPLAY_NAME: ${INITIAL_ADMIN_DISPLAY_NAME:-平台管理员} + INTEGRATION_API_KEY: ${INTEGRATION_API_KEY:-} AGENT_DEFAULT_MODEL: ${AGENT_DEFAULT_MODEL:-deepseek-v4-flash} AGENT_DEFAULT_API_BASE_URL: ${AGENT_DEFAULT_API_BASE_URL:-https://api.deepseek.com/v1} + WIKI_AUTO_COMPILE_ENABLED: ${WIKI_AUTO_COMPILE_ENABLED:-true} + WIKI_WORKER_POLL_SECONDS: ${WIKI_WORKER_POLL_SECONDS:-10} + WIKI_COMPILATION_STALE_MINUTES: ${WIKI_COMPILATION_STALE_MINUTES:-60} + WIKI_MAX_DOCUMENT_BYTES: ${WIKI_MAX_DOCUMENT_BYTES:-52428800} + WIKI_MAX_EXTRACTED_CHARS: ${WIKI_MAX_EXTRACTED_CHARS:-500000} + WIKI_SOURCE_CHUNK_CHARS: ${WIKI_SOURCE_CHUNK_CHARS:-12000} + WIKI_MAX_CHUNKS_PER_SOURCE: ${WIKI_MAX_CHUNKS_PER_SOURCE:-8} + WIKI_MAX_SOURCES_PER_RUN: ${WIKI_MAX_SOURCES_PER_RUN:-20} depends_on: db: condition: service_healthy @@ -37,6 +46,28 @@ services: - uploadsdata:/app/uploads - docsdata:/app/documents + wiki-worker: + build: ./backend + restart: unless-stopped + command: ["python", "-m", "app.wiki_worker"] + environment: + DATABASE_URL: postgresql://tinypm:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/tinypm + AGENT_DEFAULT_MODEL: ${AGENT_DEFAULT_MODEL:-deepseek-v4-flash} + AGENT_DEFAULT_API_BASE_URL: ${AGENT_DEFAULT_API_BASE_URL:-https://api.deepseek.com/v1} + WIKI_AUTO_COMPILE_ENABLED: ${WIKI_AUTO_COMPILE_ENABLED:-true} + WIKI_WORKER_POLL_SECONDS: ${WIKI_WORKER_POLL_SECONDS:-10} + WIKI_COMPILATION_STALE_MINUTES: ${WIKI_COMPILATION_STALE_MINUTES:-60} + WIKI_MAX_DOCUMENT_BYTES: ${WIKI_MAX_DOCUMENT_BYTES:-52428800} + WIKI_MAX_EXTRACTED_CHARS: ${WIKI_MAX_EXTRACTED_CHARS:-500000} + WIKI_SOURCE_CHUNK_CHARS: ${WIKI_SOURCE_CHUNK_CHARS:-12000} + WIKI_MAX_CHUNKS_PER_SOURCE: ${WIKI_MAX_CHUNKS_PER_SOURCE:-8} + WIKI_MAX_SOURCES_PER_RUN: ${WIKI_MAX_SOURCES_PER_RUN:-20} + depends_on: + db: + condition: service_healthy + volumes: + - docsdata:/app/documents + frontend: build: ./frontend restart: unless-stopped diff --git a/docs/codex-document-processing-workflow.md b/docs/codex-document-processing-workflow.md new file mode 100644 index 0000000..f0574e0 --- /dev/null +++ b/docs/codex-document-processing-workflow.md @@ -0,0 +1,187 @@ +--- +type: synthesis +status: active +tags: + - synthesis + - codex + - document-processing + - llm-wiki + - workflow +--- + +# Codex 文档处理工作流 + +## 核心判断 + +这套方法的关键不是“让 AI 总结文档”,而是把文档处理拆成两段: + +1. 导入:只做可追溯登记,不做观点加工。 +2. 编译:由 Codex/Agent 阅读 source 和原文,把稳定知识沉淀到 wiki 的 topic、concept、synthesis、idea 中。 + +这样做的价值是:原文、AI 编译、主题判断、想法和变更日志互相可追溯,另一个 Codex 项目也能复用同样的文档知识库结构。 + +## 最小目录骨架 + +```text +ProjectRoot/ + AGENTS.md + raw/ + inbox/ + sources/ + assets/ + wiki/ + index.md + current-status.md + log.md + ideas.md + sources/ + topics/ + concepts/ + synthesis/ + ideas/ + templates/ + compile-inbox.md + manifests/ + raw_sources.csv + scripts/ + ingest_raw.py + wiki_lint.py + list_inbox_sources.py + project_update.ps1 +``` + +## AGENTS.md 要写清的事 + +另一个 Codex 项目的 `AGENTS.md` 至少要固定四类规则: + +- 入口顺序:先读 `wiki/index.md`,再读相关 topic/concept/synthesis,资料不足再读 source note 和 raw 原文。 +- 目录职责:`raw/` 保留证据,`wiki/sources/` 保留 source note,`wiki/topics/` 和 `wiki/concepts/` 承载长期知识,`wiki/log.md` 记录变更。 +- 写入规则:不要把结论只留在聊天里;事实、原文观点、AI 推断、待验证假设必须分开。 +- 编译流程:只编译 `inbox` source;编译后必须更新 source status、manifest、index、current-status 和 log。 + +## 导入阶段 + +导入脚本只做机械处理,不做 LLM 总结: + +1. 扫描外部目录,例如 `E:\raw` 或项目自己的 `raw/inbox`。 +2. 对每个文件计算 `sha256`,用前 12 位生成 `src_`。 +3. 复制原文到 `raw/sources/YYYY-MM-DD/`。 +4. 生成 `wiki/sources/src_.md`,状态为 `inbox`。 +5. 写入 `manifests/raw_sources.csv`:source_id、title、original_path、vault_path、wiki_path、sha256、size、mtime、imported_at、status。 +6. 自动更新 `wiki/index.md` 的 Source Pages 表格。 +7. 在 `wiki/log.md` 追加导入记录。 + +导入阶段的原则是“证据先入库,判断后发生”。这可以防止 AI 在没有完整上下文时直接把来源改写成事实。 + +## 更新命令 + +当前 AIBrain 使用一个总入口脚本: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\aibrain_update.ps1 -SourceDir E:\raw +``` + +迁移到别的项目时,可以改名为 `scripts\project_update.ps1`,但结构保持一致: + +```powershell +python scripts\ingest_raw.py --source-dir <外部资料目录> --vault-root <项目根目录> +python scripts\wiki_lint.py --vault-root <项目根目录> +python scripts\list_inbox_sources.py --vault-root <项目根目录> +``` + +如果 `INBOX_SOURCE_COUNT=0`,停止,不要重复改写 wiki。只有存在 inbox source 时才进入编译。 + +## 编译阶段 + +Codex 处理 inbox source 时按这个顺序: + +1. 读 `AGENTS.md` 和 `wiki/index.md`。 +2. 读 `wiki/log.md`,确认最近是否有人工编辑或上一轮未完成工作。 +3. 读 `manifests/raw_sources.csv`,只选择 `status=inbox` 的 source。 +4. 读每个 `wiki/sources/src_*.md` 的元数据。 +5. 读对应 `raw/sources/` 原文。 +6. 读相关已有 topic、concept、synthesis、ideas,优先更新旧页,避免近义重复页。 +7. 对每个 source note 填写 AI 编译区: + - 稳定要点:5-10 条,标注“原文观点”或“AI 推断”。 + - 可复用概念:链接到已有或新增 concept。 + - 相关主题:链接到 topic。 + - 待验证问题:数字、榜单、市场判断、产品声称、法律/金融/医疗相关内容必须列入。 +8. 把跨 source 的长期知识写入 topic/concept/synthesis。 +9. 只有形成可行动假设时才写 idea。 +10. 更新 source note、manifest、index、current-status、ideas index 和 log。 + +## 编译判断规则 + +- 原文说了什么,不等于事实成立;未核验数字统一写成 source claim。 +- AI 推断必须显式标出,不能伪装成原文观点。 +- 新 concept 必须可复用;只服务单篇文章的,不要新建。 +- 新 synthesis 必须列出相关 source pages。 +- 新 idea 必须包含依据、反证和下一步验证。 +- 大批量资料可以先按来源类型分组,例如日报、访谈、论文、产品文档、内部规范。 + +## 验证闭环 + +每次编译完成后运行: + +```powershell +python scripts\wiki_lint.py +python scripts\ingest_raw.py --source-dir <外部资料目录> --dry-run +python scripts\list_inbox_sources.py +``` + +期望结果: + +- 没有断链。 +- manifest 与 source note status 一致。 +- 没有重复导入。 +- `INBOX_SOURCE_COUNT=0`。 + +如果检查失败,先修 wiki,再结束任务。 + +## 适配其他项目时要改的参数 + +- 项目根目录:例如 `E:\Work\SomeProject\KnowledgeBase`。 +- 外部资料目录:例如 `E:\raw`、`D:\Downloads\docs`、项目内 `raw/inbox`。 +- source title 规则:日报、会议纪要、PDF、网页、代码文档可能需要不同标题提取逻辑。 +- 支持文件类型:Markdown/TXT/CSV/JSON 可直接预览;PDF/DOCX/图片应先转换为 Markdown 或在 source note 中保留解析说明。 +- topic/concept 分类:按项目业务重建,不要照搬 AIBrain 的 AI 主题分类。 +- 验证规则:高风险项目要增加事实核验、权限审计、脱敏检查或人工 review。 + +## 可直接给 Codex 的执行提示 + +```text +这是一个按 LLM Wiki 维护的文档知识库。请先读 AGENTS.md 和 wiki/index.md。 +运行 scripts\project_update.ps1 导入外部资料。 +如果 INBOX_SOURCE_COUNT=0,停止,不要重复改写 wiki。 +如果存在 inbox source,只编译这些 source: +1. 读 source note 和 raw 原文。 +2. 写 5-10 条稳定要点,区分原文观点、AI 推断、待验证。 +3. 更新或新建可复用 concepts、长期 topics、必要的 synthesis。 +4. 有可行动假设时才写 idea。 +5. 更新 source status、manifest、index、current-status、log。 +6. 运行 wiki_lint、dry-run ingest、list_inbox_sources,直到无断链且 INBOX_SOURCE_COUNT=0。 +``` + +## 常见失败模式 + +- 直接总结 raw 文件,但不写回 wiki,导致下次 agent 无法复用。 +- 重复编译已 `compiled` 的 source,污染旧结论。 +- 看到数字就写成事实,没有标注 source claim。 +- 每篇文章都新建 topic/concept,导致知识库碎片化。 +- 编译后忘记改 manifest 或 source frontmatter,自动流程仍然认为它是 inbox。 +- 只跑导入不跑 lint,留下断链或状态不一致。 + +## 相关来源 + +- [[src_eb563969716d]] +- [[src_8eb0c1cf8b5d]] +- [[src_bba9c32cb75d]] +- [[src_7a55210f1baa]] + +## 相关链接 + +- [[llm-wiki-compiled-knowledge]] +- [[agent-workflow-governance]] +- [[skill-routing-contract]] +- [[agent-data-flywheel]] +- [[agent-workflow-skill-lifecycle]] diff --git a/docs/integration_api.md b/docs/integration_api.md new file mode 100644 index 0000000..58883a0 --- /dev/null +++ b/docs/integration_api.md @@ -0,0 +1,167 @@ +# TinyPM 外部集成 API + +TinyPM 提供一组受限的机器接口,供 Codex、脚本或其他自动化客户端读取和写入项目数据。接口只覆盖项目业务数据,不开放用户、角色和 Agent 模型密钥管理。 + +## 启用方式 + +在 `.env` 中配置独立密钥: + +```env +INTEGRATION_API_KEY=replace-with-a-long-random-key +``` + +重建后端服务: + +```bash +docker compose up -d --build backend +``` + +客户端可选择以下任一认证方式: + +```http +X-API-Key: +``` + +或: + +```http +Authorization: Bearer +``` + +完整 OpenAPI 文档位于 `http://localhost:6100/docs`,接口前缀为 `/api/integration`。 + +## 可用资源 + +| 资源 | 查询 | 创建 | 更新 | +| --- | --- | --- | --- | +| 项目 | `GET /projects`、`GET /projects/{id}` | `POST /projects` | `PUT /projects/{id}` | +| 里程碑 | `GET /projects/{id}/milestones` | `POST /projects/{id}/milestones` | `PUT /milestones/{id}` | +| 计划 | `GET /projects/{id}/plans` | `POST /projects/{id}/plans` | `PUT /plans/{id}` | +| 需求 | `GET /projects/{id}/requirements` | `POST /projects/{id}/requirements` | `PUT /requirements/{id}` | +| 问题 | `GET /projects/{id}/issues` | `POST /projects/{id}/issues` | `PUT /issues/{id}` | +| 关键路径 | `GET /projects/{id}/critical-path` | - | - | +| 项目知识 | `GET /projects/{id}/knowledge` | `POST /projects/{id}/knowledge`、`POST /projects/{id}/knowledge/batch` | 使用相同 `external_id` 再次写入 | +| 项目周报 | `GET /projects/{id}/weekly-reports`、`GET /weekly-reports/{report_id}` | `POST /projects/{id}/weekly-reports/{report_id}/facts/batch` | 使用相同 `external_id` 再次写入 | + +集成 API 暂不提供删除操作,避免自动化客户端误删项目数据。 + +## 导入项目知识 + +支持的首期来源类型: + +- `meeting_notes`:会议纪要、会议决策、行动项。 +- `development_detail`:技术方案、实现细节、调试结论。 +- `chat_record`:群聊、私聊或 Agent 对话记录。 +- `document`:已转成文本或 Markdown 的项目文档。 +- `other`:其他项目背景资料。 + +单条导入示例: + +```bash +curl -X POST "http://localhost:6100/api/integration/projects/1/knowledge" \ + -H "X-API-Key: $INTEGRATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "7月14日项目周会纪要", + "source_type": "meeting_notes", + "content": "## 决策\n采用方案B。\n\n## 行动项\n- 张三:7月18日前完成接口联调。", + "external_id": "meeting-2026-07-14", + "tags": ["周会", "接口联调"], + "occurred_at": "2026-07-14T10:00:00+08:00" + }' +``` + +`external_id` 是外部来源中的稳定标识。相同项目内再次提交同一 `external_id` 时,TinyPM 会更新原条目,不会生成重复数据。 + +`occurred_at` 为必填项,用于确定资料属于哪个周报周期;不能用导入时间代替资料实际发生时间。 + +批量导入请求体: + +```json +{ + "entries": [ + { + "title": "登录模块开发细节", + "source_type": "development_detail", + "content": "刷新令牌只在服务端保存,前端只持有短期访问令牌。", + "external_id": "dev-note-auth-001", + "tags": ["认证", "安全"], + "occurred_at": "2026-07-15T14:00:00+08:00" + }, + { + "title": "接口联调群聊记录", + "source_type": "chat_record", + "content": "测试环境网关已切换,后续请求统一使用新域名。", + "external_id": "chat-api-20260714", + "occurred_at": "2026-07-16T09:30:00+08:00" + } + ] +} +``` + +检索示例,可使用资料发生时间限制周报分析范围: + +```bash +curl "http://localhost:6100/api/integration/projects/1/knowledge?query=接口联调&occurred_from=2026-07-13T00:00:00%2B08:00&occurred_to=2026-07-19T23:59:59%2B08:00&limit=10" \ + -H "X-API-Key: $INTEGRATION_API_KEY" +``` + +## 导入周报候选事实 + +周报周期先由项目经理在浏览器中创建。机器客户端可以读取周期和事实底稿,也可以幂等导入候选事实,但不能调用批准、归档或发布操作。 + +查询项目周报: + +```bash +curl "http://localhost:6100/api/integration/projects/1/weekly-reports" \ + -H "X-API-Key: $INTEGRATION_API_KEY" +``` + +批量导入候选事实: + +```bash +curl -X POST "http://localhost:6100/api/integration/projects/1/weekly-reports/8/facts/batch" \ + -H "X-API-Key: $INTEGRATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "facts": [ + { + "external_id": "codex-build-20260717", + "category": "progress", + "module": "部署", + "statement": "测试环境构建已通过,等待项目经理核验后写入正式周报。", + "business_status": "关注", + "owner": "张三", + "evidence": [ + { + "source_type": "external", + "source_title": "CI 构建记录", + "source_uri": "https://ci.example.test/build/123", + "source_excerpt": "build status: success" + } + ] + } + ] + }' +``` + +首次导入或内容发生变化的外部事实都会保存为 `pending_confirmation`,并清空既有核验人和核验时间;完全相同的幂等重试不会撤销已有人工核验。只有浏览器中的 `manager` 或 `admin` 能将变化后的事实重新改为已核验。 + +客户端提交的证据会被后端统一归类为 `external`,并由后端计算内容快照哈希。客户端不能把证据伪装成 TinyPM 内部的计划、问题或知识记录;结构化来源引用只由项目内采集器生成并校验所属项目。 + +## 交给 Codex 的调用约束 + +建议在 Codex 任务中明确以下规则: + +1. 从本地 `.env` 读取 `INTEGRATION_API_KEY`,禁止在聊天回复、日志或提交中输出密钥。 +2. 写入前先用 `GET /api/integration/projects` 确认目标 `project_id`。 +3. 同步会议纪要、开发细节或聊天记录时提供稳定 `external_id`。 +4. 项目计划、需求和问题更新前先查询目标记录 ID,禁止用名称猜测 ID。 +5. 不把密码、Token、客户隐私和个人敏感信息写入知识库;导入聊天记录前先做脱敏。 +6. 导入周报事实时使用稳定 `external_id`;只提交可追溯的事实和证据,不把推测写成完成、承诺或客户结论。 + +## AI 助理如何使用知识库 + +TinyPM 项目数据 MCP 已提供 `search_project_knowledge` 工具。智能助理在分析会议决定、历史讨论、技术背景和开发细节时,可以先检索知识库,再结合项目计划、需求、问题和关键路径给出结论。 + +首期为关键词检索,适合明确的人名、模块名、决策词和技术关键词。后续可在数据量增大后增加文档分块、向量检索、引用定位和敏感级别控制。 diff --git a/docs/llm-wiki-architecture.md b/docs/llm-wiki-architecture.md new file mode 100644 index 0000000..0e7cf39 --- /dev/null +++ b/docs/llm-wiki-architecture.md @@ -0,0 +1,117 @@ +# TinyPM LLM Wiki 架构与实施说明 + +## 目标 + +TinyPM 将项目文档、项目知识条目和绑定项目的智能助理会话,持续沉淀为可检索、可追溯、可导出的项目知识库。 + +这里的“编译”不是对每份资料做一次孤立摘要,而是让新来源增量更新项目已有的 topic、concept、synthesis 和 idea 页面。原始来源与 AI 生成的长期知识必须分层保存。 + +## 核心边界 + +流程固定分为两个阶段: + +1. **来源入箱**:机械保存来源快照、哈希、来源地址和发生时间,不让 LLM 改写原文。 +2. **知识编译**:只处理 `inbox` 来源;优先更新已有 Wiki 页面,区分来源观点、AI 推断和待验证问题,并记录页面与来源之间的关系。 + +TinyPM 的数据库是运行时权威数据源,Obsidian 目录是可重复生成的投影。这样既能支持页面检索、状态筛选和并发控制,也能导出普通 Markdown 文件供 Obsidian、Codex 或其他 Agent 使用。 + +## 数据结构 + +### `project_wiki_sources` + +保存不可变来源快照: + +- `source_id` 使用正文与语义元数据的 SHA256 前缀生成,例如 `src_7d91c25df1a3`。 +- `origin_kind` 标记 `knowledge_entry`、`document` 或 `agent_chat`。 +- `origin_ref` 指向业务记录,但业务记录删除后来源快照仍可追溯。 +- `raw_content` 保存提取后的原文;上传的原始文件仍由项目文档模块保管。 +- `status` 使用 `inbox`、`compiling`、`compiled`、`failed`、`unsupported`。 +- `compiled_markdown` 保存该来源的稳定要点、AI 推断与待验证问题。 + +快照内容和语义元数据相同的来源在同一项目内按哈希去重;同一业务记录内容变化时会形成新的来源快照,不覆盖已编译的旧来源,尚未编译的旧版本标记为 `superseded`。 + +### `project_wiki_pages` + +保存长期知识页: + +- 页面类型限定为 `topic`、`concept`、`synthesis`、`idea`。 +- `(project_id, slug)` 唯一,更新同名页面时递增 `revision`。 +- `content_markdown` 保存 Obsidian 兼容 Markdown。 + +### `project_wiki_page_sources` + +保存 Wiki 页面与来源快照的多对多关系,并区分 `source_claim`、`ai_inference`、`evidence`。 + +### `project_wiki_compilation_runs` + +记录触发方式、调用模型、来源数量、页面更新数量、失败原因和 lint 结果。每个项目同一时间只允许一个编译批次运行。 + +## 自动入箱与编译触发 + +- 新建或更新项目知识条目:自动生成不可变来源快照。 +- 上传项目文档:后端统一提取文本并生成来源快照;浏览器预览结果不参与入库。 +- TinyPM 智能助理会话:只收录绑定了 `project_id` 且成功完成的用户/助理轮次;错误回复不收录。 +- 外部聊天或文档:继续通过 Integration API 作为 `chat_record` 或 `document` 知识条目导入,然后进入同一个来源收件箱。 + +来源行本身就是持久队列事实。HTTP 请求完成后由 FastAPI 后台任务立即尝试唤醒编译,独立的 `wiki-worker` 也会持续扫描 `inbox`,因此容器在回调执行前重启不会丢失任务。编译使用来源提交用户已经保存的智能助理模型配置;没有 API Key 时保留 `inbox` 状态,并在手动编译批次中给出可重试错误。外部 Integration API 没有用户模型身份,其来源会等待有权限用户手动编译。 + +## 编译契约 + +每个来源按受控长度分段送入 LLM。模型必须返回结构化 JSON,其中包含: + +- 是否存在值得长期沉淀的内容; +- 来源编译说明; +- 要新增或更新的 Wiki 页面; +- 每个页面与来源的关系类型。 + +编译提示会提供现有页面索引和相关页面正文,并要求: + +- 优先更新已有页面,避免近义页面重复; +- 原文没有明确表达的内容不得写成来源事实; +- 推断必须明确放在“AI 推断”; +- 数字、承诺、法规和未核验结论进入“待验证”; +- 每个更新页面必须链接至少一个 `[[src_xxx]]` 来源页。 + +模型输出经过 Pydantic 校验。未知 Wiki 链接会降级为普通文本,避免导出后产生断链。 + +## 文档提取范围 + +首版后端直接支持: + +- Markdown、TXT、JSON、CSV、LOG、XML、YAML; +- DOCX; +- XLSX; +- PPTX; +- 可提取文本层的 PDF。 + +图片、扫描 PDF 和旧版二进制 Office 文件会以 `unsupported` 保存来源登记和原因,不伪造正文。后续可在来源提取层接入 OCR,不影响 Wiki 编译模型。 + +## Obsidian 投影 + +每个项目可生成并下载如下目录: + +```text +project-/ + raw/sources/ + wiki/index.md + wiki/current-status.md + wiki/log.md + wiki/sources/ + wiki/topics/ + wiki/concepts/ + wiki/synthesis/ + wiki/ideas/ + manifests/raw_sources.csv +``` + +投影只从数据库重建,不反向覆盖数据库。若未来需要支持人在 Obsidian 中编辑,应新增显式的双向同步和冲突策略,不能直接复用当前导出目录。 + +## 首版验收标准 + +1. 上传一份可解析文档后,出现一个带哈希的来源快照。 +2. 完成一轮绑定项目的成功聊天后,出现一个聊天来源快照。 +3. 相同正文不会重复入箱,正文变化不会覆盖旧快照。 +4. 编译成功后来源状态变为 `compiled`,页面能追溯到来源。 +5. 编译失败不会影响文档上传或聊天,失败来源可再次编译。 +6. 项目知识库页面可查看状态、来源、Wiki 页面和编译记录。 +7. 导出的 ZIP 可被 Obsidian 直接打开,且 lint 不存在断链。 diff --git a/docs/project-risk-warning-design.md b/docs/project-risk-warning-design.md new file mode 100644 index 0000000..34ffcf6 --- /dev/null +++ b/docs/project-risk-warning-design.md @@ -0,0 +1,40 @@ +# 项目风险预警与 Agent 识别设计 + +## 目标 + +在 TinyPM 现有项目、计划、里程碑、需求和问题数据之上,增加一条可解释的风险闭环: + +1. 后端规则引擎按当前数据计算风险,不依赖大模型才能运行。 +2. API 返回风险等级、命中依据、对象和建议动作,前端直接展示。 +3. Agent 通过 `get_project_risks` MCP 工具读取同一份结果,再做归纳和处置建议。 +4. Agent 必须区分“规则命中”和“AI 推断”,不能把推断伪装成系统事实。 + +## 首期规则 + +| 规则编码 | 风险等级 | 判定条件 | +|---|---|---| +| `PROJECT_DEADLINE_OVERDUE` | 高 | 项目计划结束日期已过且项目未完成 | +| `PLAN_OVERDUE` | 中/高 | 非汇总计划已过期且进度不足 100%;逾期 7 天及以上为高 | +| `PLAN_DUE_SOON` | 中/高 | 计划 7 天内到期且进度低于 80%;2 天内到期且进度低于 50% 为高 | +| `PLAN_PROGRESS_STALE` | 中 | 进行中计划 10 天以上没有更新 | +| `PLAN_OWNER_MISSING` | 低/中 | 未完成计划没有负责人;临期或已逾期时升级为中 | +| `PLAN_DEPENDENCY_BLOCKED` | 中/高 | 后续计划已启动,但前置计划尚未完成;前置已逾期时为高 | +| `MILESTONE_OVERDUE` | 高 | 里程碑日期已过且未完成 | +| `CRITICAL_ISSUE_OPEN` | 高 | 严重、关键或致命问题尚未关闭 | +| `HIGH_REQUIREMENT_PENDING` | 中 | 高优先级需求保持待评审等未闭环状态 14 天及以上 | + +## 分层边界 + +- 规则层:只使用结构化事实,结果稳定、可测试、可追溯。 +- API/MCP 层:对 Web 页面和 Agent 暴露完全一致的风险报告。 +- Agent 层:优先读取规则报告;可以结合知识库发现语义风险,但必须标记为“AI 推断”。 +- 处置层:首期沿用已有“风险转问题”能力,只有用户明确要求时才写入问题记录。 + +## 验收标准 + +- 同一项目在同一 `as_of` 日期下返回稳定的风险项和总体等级。 +- 每条风险都包含规则编码、对象、证据和建议动作。 +- `/api/projects/{project_id}/risks` 可供前端直接读取。 +- Agent 的通用、项目总审、排期和风险转问题技能都能调用 `get_project_risks`。 +- 智能助理工作台不再使用“进度低于 30%”的前端猜测,而展示后端风险规则结果。 +- 后端规则测试和前端构建通过。 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..ddb3174 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +npm-debug.log* +.git +.gitignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1a7ea49..b52b42c 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,7 +1,7 @@ FROM node:22.14.0-bookworm-slim AS build WORKDIR /app -COPY package.json ./ -RUN npm install +COPY package.json package-lock.json ./ +RUN npm ci COPY . . RUN npm run build diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3f36c5..e6028d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinypm-frontend", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinypm-frontend", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "axios": "^1.7.7", "jszip": "^3.10.1", @@ -16,6 +16,7 @@ "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "react-router-dom": "^6.26.2", + "remark-gfm": "^4.0.1", "xlsx": "^0.18.5" }, "devDependencies": { @@ -1726,6 +1727,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -2168,6 +2181,16 @@ "node": ">=12.0.0" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2177,6 +2200,22 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -2201,6 +2240,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -2399,6 +2539,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -3041,6 +3302,24 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -3074,6 +3353,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4446db2..e9037f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "tinypm-frontend", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "scripts": { @@ -14,9 +14,10 @@ "lucide-react": "^0.447.0", "mammoth": "^1.8.0", "react": "^18.3.1", - "react-markdown": "^9.0.1", "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", "react-router-dom": "^6.26.2", + "remark-gfm": "^4.0.1", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/frontend/src/constants/app.js b/frontend/src/constants/app.js index 2c58554..9099930 100644 --- a/frontend/src/constants/app.js +++ b/frontend/src/constants/app.js @@ -1,2 +1,2 @@ export const APP_NAME = 'TinyPM'; -export const APP_VERSION = '1.1.0'; +export const APP_VERSION = '1.2.0'; diff --git a/frontend/src/pages/AgentHome.jsx b/frontend/src/pages/AgentHome.jsx index eb04327..427405e 100644 --- a/frontend/src/pages/AgentHome.jsx +++ b/frontend/src/pages/AgentHome.jsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, Bot, CheckCircle2, + Copy, FileSpreadsheet, FolderKanban, KeyRound, @@ -16,11 +17,25 @@ import { Sparkles, Wrench, } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import api from '../api/client'; const ACTIVE_SESSION_STORAGE_KEY = 'tinypm-agent-active-session-id'; const DEFAULT_MODEL = 'deepseek-v4-flash'; const DEFAULT_SKILL_ID = 'general'; +const MARKDOWN_COMPONENTS = { + a: ({ children, href }) => ( + + {children} + + ), + table: ({ children }) => ( +
+ {children}
+
+ ), +}; const DEFAULT_SYSTEM_PROMPT = `你是一位资深的车载电子项目经理(PM),专注 OTA(Over-The-Air)升级与远程诊断领域,具备 10 年以上汽车电子行业经验。 **专业背景:** @@ -41,9 +56,12 @@ const DEFAULT_SYSTEM_PROMPT = `你是一位资深的车载电子项目经理(P - 熟悉诊断流程:预编程(Pre-programming)、主编程(Main programming)、后编程(Post-programming) - 了解回滚(Rollback)机制与故障安全(Fail-safe)策略`; -function getPlanRisk(plan) { - if (plan.status === '已延迟') return '高'; - if ((plan.progress_pct || 0) < 30 && plan.planned_end) return '中'; +function getPlanRisk(plan, riskItems = []) { + const levels = riskItems + .filter((item) => item.entity_type === 'plan' && Number(item.entity_id) === Number(plan.id)) + .map((item) => item.level); + if (levels.includes('高')) return '高'; + if (levels.includes('中')) return '中'; return '低'; } @@ -73,6 +91,37 @@ function normalizeMessages(messages) { })); } +async function writeClipboardText(value) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch (error) { + void error; + } + } + + const activeElement = document.activeElement; + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + if (!document.execCommand('copy')) { + throw new Error('浏览器未完成复制操作'); + } + } finally { + textarea.remove(); + activeElement?.focus?.(); + } +} + function buildPlanReviewPrompt(project, plan) { if (!project || !plan) { return ''; @@ -98,7 +147,10 @@ function buildPlanReviewPrompt(project, plan) { const AgentHome = () => { const [projects, setProjects] = useState([]); const [plans, setPlans] = useState([]); + const [riskReport, setRiskReport] = useState(null); const [skills, setSkills] = useState([]); + const [promptTemplates, setPromptTemplates] = useState([]); + const [outputTemplates, setOutputTemplates] = useState([]); const [sessions, setSessions] = useState([]); const [settings, setSettings] = useState({ model: DEFAULT_MODEL, @@ -110,6 +162,7 @@ const AgentHome = () => { const [selectedPlanId, setSelectedPlanId] = useState(''); const [activeSessionId, setActiveSessionId] = useState(''); const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL_ID); + const [selectedPromptTemplateId, setSelectedPromptTemplateId] = useState('ota_pm'); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(true); @@ -123,6 +176,14 @@ const AgentHome = () => { const [notice, setNotice] = useState(''); const [projectAuditNotice, setProjectAuditNotice] = useState(''); const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT); + const [copyFeedback, setCopyFeedback] = useState({ messageKey: '', status: '' }); + const copyResetTimer = useRef(null); + + useEffect(() => () => { + if (copyResetTimer.current) { + clearTimeout(copyResetTimer.current); + } + }, []); const selectedProject = useMemo( () => projects.find((project) => String(project.id) === String(selectedProjectId)), @@ -134,11 +195,28 @@ const AgentHome = () => { [plans, selectedPlanId], ); + const selectedPlanRisks = useMemo( + () => (riskReport?.items || []).filter( + (item) => item.entity_type === 'plan' && Number(item.entity_id) === Number(selectedPlan?.id), + ), + [riskReport, selectedPlan], + ); + const selectedSkill = useMemo( () => skills.find((skill) => skill.id === selectedSkillId) || skills[0] || null, [skills, selectedSkillId], ); + const selectedPromptTemplate = useMemo( + () => promptTemplates.find((template) => template.id === selectedPromptTemplateId) || promptTemplates[0] || null, + [promptTemplates, selectedPromptTemplateId], + ); + + const selectedOutputTemplate = useMemo( + () => outputTemplates.find((template) => template.id === selectedSkill?.output_template_id) || null, + [outputTemplates, selectedSkill], + ); + const hasProjectContext = Boolean(selectedProjectId); const displayMessages = useMemo( () => (messages.length > 0 ? messages : [buildWelcomeMessage(selectedSkill?.name || '通用项目助理')]), @@ -149,9 +227,9 @@ const AgentHome = () => { const projectCount = projects.length; const activeCount = projects.filter((project) => project.status === '进行中').length; const planCount = selectedProject ? plans.length : projects.reduce((sum, project) => sum + (project.plan_count || 0), 0); - const riskCount = plans.filter((plan) => getPlanRisk(plan) !== '低').length; + const riskCount = riskReport?.total_count || 0; return { projectCount, activeCount, planCount, riskCount }; - }, [plans, projects, selectedProject]); + }, [plans, projects, riskReport, selectedProject]); const fetchSessionSummaries = useCallback(async () => { const response = await api.get('/agent/sessions'); @@ -179,15 +257,26 @@ const AgentHome = () => { setLoading(true); setError(''); try { - const [settingsResponse, projectsResponse, skillsResponse, sessionsResponse] = await Promise.all([ + const [ + settingsResponse, + projectsResponse, + skillsResponse, + promptTemplatesResponse, + outputTemplatesResponse, + sessionsResponse, + ] = await Promise.all([ api.get('/agent/settings'), api.get('/projects'), api.get('/agent/skills'), + api.get('/agent/prompt-templates'), + api.get('/agent/output-templates'), api.get('/agent/sessions'), ]); const nextProjects = projectsResponse.data; const nextSkills = skillsResponse.data; + const nextPromptTemplates = promptTemplatesResponse.data; + const nextOutputTemplates = outputTemplatesResponse.data; const nextSessions = sessionsResponse.data; const fallbackProjectId = nextProjects[0] ? String(nextProjects[0].id) : ''; const preferredSessionId = localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY); @@ -195,7 +284,10 @@ const AgentHome = () => { setSettings(settingsResponse.data); setProjects(nextProjects); setSkills(nextSkills); + setPromptTemplates(nextPromptTemplates); + setOutputTemplates(nextOutputTemplates); setSessions(nextSessions); + setSelectedPromptTemplateId(nextSkills[0]?.prompt_template_id || nextPromptTemplates[0]?.id || 'ota_pm'); if (nextSessions.length > 0) { const preferredSession = @@ -220,14 +312,19 @@ const AgentHome = () => { const fetchPlans = useCallback(async (projectId) => { if (!projectId) { setPlans([]); + setRiskReport(null); setSelectedPlanId(''); return; } setPlansLoading(true); try { - const response = await api.get(`/projects/${projectId}/plans`); - setPlans(response.data); - setSelectedPlanId(response.data[0] ? String(response.data[0].id) : ''); + const [plansResponse, riskResponse] = await Promise.all([ + api.get(`/projects/${projectId}/plans`), + api.get(`/projects/${projectId}/risks`), + ]); + setPlans(plansResponse.data); + setRiskReport(riskResponse.data); + setSelectedPlanId(plansResponse.data[0] ? String(plansResponse.data[0].id) : ''); } catch (err) { setError(err.response?.data?.detail || '加载项目计划失败'); } finally { @@ -245,6 +342,13 @@ const AgentHome = () => { setProjectAuditNotice(''); }, [fetchPlans, selectedProjectId]); + useEffect(() => { + const skill = skills.find((item) => item.id === selectedSkillId); + if (skill?.prompt_template_id) { + setSelectedPromptTemplateId(skill.prompt_template_id); + } + }, [selectedSkillId, skills]); + const handleSettingChange = (field, value) => { setSettings((previous) => ({ ...previous, [field]: value })); }; @@ -338,6 +442,10 @@ const AgentHome = () => { const handleSkillChange = async (value) => { setSelectedSkillId(value); + const nextSkill = skills.find((skill) => skill.id === value); + if (nextSkill?.prompt_template_id) { + setSelectedPromptTemplateId(nextSkill.prompt_template_id); + } if (!activeSessionId) { return; } @@ -351,6 +459,12 @@ const AgentHome = () => { } }; + const handleApplyPromptTemplate = () => { + if (!selectedPromptTemplate) return; + setSystemPrompt(selectedPromptTemplate.content); + setNotice(`已套用 Prompt 模板:${selectedPromptTemplate.name}`); + }; + const handleSaveSystemPrompt = async () => { setPromptSaving(true); setError(''); @@ -400,6 +514,7 @@ const AgentHome = () => { const response = await api.post('/agent/chat', payload); await loadSessionDetail(response.data.session_id, selectedProjectId); await fetchSessionSummaries(); + setNotice('本轮项目对话已进入知识编译队列'); } catch (err) { if (sessionId) { try { @@ -428,6 +543,27 @@ const AgentHome = () => { await sendAgentMessage(input); }; + const handleCopyMarkdown = async (messageKey, content) => { + if (!content) return; + + if (copyResetTimer.current) { + clearTimeout(copyResetTimer.current); + } + + try { + await writeClipboardText(content); + setCopyFeedback({ messageKey, status: 'success' }); + } catch (copyError) { + void copyError; + setCopyFeedback({ messageKey, status: 'error' }); + } + + copyResetTimer.current = setTimeout(() => { + setCopyFeedback({ messageKey: '', status: '' }); + copyResetTimer.current = null; + }, 2000); + }; + const handleQuickPrompt = (prompt) => { if (sending || !hasProjectContext) return; sendAgentMessage(prompt); @@ -508,7 +644,11 @@ const AgentHome = () => {
AI 关注项 {dashboardStats.riskCount} - 进度、状态和计划周期校验 + + {riskReport + ? `高 ${riskReport.high_count} · 中 ${riskReport.medium_count} · 低 ${riskReport.low_count}` + : '选择项目后执行预警规则'} +
@@ -569,6 +709,39 @@ const AgentHome = () => { 计划记录 {plans.length} +
+ 总体风险 + {riskReport?.overall_level || '低'} · {riskReport?.score || 0}分 +
+ + )} + + {riskReport?.total_count > 0 && ( +
+
+
+ + 规则预警 {riskReport.total_count} 项 + 规则版本 {riskReport.rules_version} +
+ +
+
+ {riskReport.items.slice(0, 4).map((item) => ( +
+ {item.level} + {item.title} + {item.description} +
+ ))} +
)} @@ -622,7 +795,7 @@ const AgentHome = () => { - + {plan.status} @@ -673,9 +846,23 @@ const AgentHome = () => {
风险等级 - {getPlanRisk(selectedPlan)} + {getPlanRisk(selectedPlan, riskReport?.items)}
+ {selectedPlanRisks.length > 0 && ( +
+ {selectedPlanRisks.map((item) => ( +
+ {item.level} +
+ {item.title} + {item.recommendation} +
+
+ ))} +
+ )} +
  • @@ -774,6 +961,34 @@ const AgentHome = () => { + {selectedSkill && ( +
    +
    +
    + {selectedSkill.category} + {selectedSkill.name} +

    {selectedSkill.description}

    +
    + + {selectedSkill.can_write ? '允许回写' : '只读分析'} + +
    +
    + {(selectedSkill.tool_names || []).map((toolName) => ( + + {toolName} + + ))} +
    + {selectedOutputTemplate && ( +
    + 输出模板:{selectedOutputTemplate.name} + {selectedOutputTemplate.description} +
    + )} +
    + )} +
    {
    - - - + {(selectedSkill?.quick_prompts || []).map((quickPrompt, index) => ( + + ))}
    @@ -824,9 +1038,25 @@ const AgentHome = () => { 系统 Prompt 当前会话的角色设定,后续对话都会使用这段 Prompt。
    - +
    + + + +