feat: 回忆录证据血缘与内部评测可追溯,顺带对齐本地评测台与 CI

数据库与模型:新增多版迁移(章节证据快照、对话血缘、记忆事实/时间线 lineage 等),把「成稿 ↔ 对话/记忆」的溯源信息落到表结构里。
业务链路:会话与 WS、回忆录/故事流水线、记忆写入与 enrichment 等跟着接上线索与快照;新增章节证据快照与评测侧 EvalTraceService 等模块,方便组评审用的证据包。
内部评测:自动化 run 与手工 memoir 评审共用可追溯证据;rubric/ judge 相关脚本与文档有配套调整。
app-eval-web:Memoir/实验详情里能展开看证据摘要与 evidence_trace(含对话轮次 id);Vite 代理与 development.sh 注入的 API 端口与当前默认内部评测端口一致,避免改端口后页面连错服务。
工程杂项:GitHub Actions / 仓库说明有更新;各适配器与支付/配额/plan 等多处为小改动或跟随主改动的收尾;新增/扩充了?
This commit is contained in:
Kevin
2026-04-08 15:37:09 +08:00
parent 6772e1269c
commit 309a051038
109 changed files with 4125 additions and 858 deletions

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""回填 chapters.evidence_bundle_jsonPhase 九:历史 chapter 无快照时可批量物化)。
用法(需在已执行 Alembic 0009+ 的库上)::
cd api && uv run python scripts/backfill_chapter_evidence_snapshots.py
cd api && uv run python scripts/backfill_chapter_evidence_snapshots.py --user-id <uuid>
"""
from __future__ import annotations
import argparse
from sqlalchemy import select
from app.features.auth import models as _auth_models # noqa: F401
from app.features.conversation import models as _conv_models # noqa: F401
from app.features.memory import models as _memory_models # noqa: F401
from app.features.memoir import models as _memoir_models # noqa: F401
from app.features.payment import models as _payment_models # noqa: F401
from app.features.story import models as _story_models # noqa: F401
from app.features.user import models as _user_models # noqa: F401
from app.core.db import SessionLocal
from app.features.memoir.chapter_evidence_snapshot import (
refresh_chapter_evidence_snapshot_sync,
)
from app.features.memoir.models import Chapter
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--user-id", default="", help="仅刷新该用户的章节;默认全表")
p.add_argument("--limit", type=int, default=0, help="最多处理条数0 表示不限制")
args = p.parse_args()
uid = (args.user_id or "").strip()
session = SessionLocal()
n_ok = 0
try:
stmt = select(Chapter.id)
if uid:
stmt = stmt.where(Chapter.user_id == uid)
if args.limit > 0:
stmt = stmt.limit(args.limit)
ids = list(session.execute(stmt).scalars().all())
for cid in ids:
if refresh_chapter_evidence_snapshot_sync(session, str(cid)):
n_ok += 1
session.commit()
print(f"refreshed_snapshots={n_ok} chapter_rows={len(ids)}")
finally:
session.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""回填 segments.user_message_id / lineage_json由既有 conversation_messages 配对)。
用法::
cd api && uv run python scripts/backfill_segment_dialogue_lineage.py
cd api && uv run python scripts/backfill_segment_dialogue_lineage.py --limit 500
"""
from __future__ import annotations
import argparse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.features.auth import models as _auth_models # noqa: F401
from app.features.conversation import models as _conv_models # noqa: F401
from app.features.memory import models as _memory_models # noqa: F401
from app.features.memoir import models as _memoir_models # noqa: F401
from app.features.payment import models as _payment_models # noqa: F401
from app.features.story import models as _story_models # noqa: F401
from app.features.user import models as _user_models # noqa: F401
from app.core.db import SessionLocal
from app.features.conversation.lineage_schemas import DialogueLineage
from app.features.conversation.models import Conversation, ConversationMessage, Segment
def _first_message_for_segment(
session: Session, *, segment_id: str, role: str
) -> ConversationMessage | None:
stmt = (
select(ConversationMessage)
.where(
ConversationMessage.segment_id == segment_id,
ConversationMessage.role == role,
)
.order_by(ConversationMessage.created_at.asc(), ConversationMessage.id.asc())
.limit(1)
)
return session.execute(stmt).scalar_one_or_none()
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--limit", type=int, default=0, help="最多处理 segment 条数0 不限制")
args = p.parse_args()
session = SessionLocal()
n = 0
try:
stmt = (
select(Segment)
.join(Conversation, Segment.conversation_id == Conversation.id)
.where(
Segment.lineage_json.is_(None),
Conversation.deleted_at.is_(None),
)
.order_by(Segment.created_at.asc())
)
if args.limit > 0:
stmt = stmt.limit(args.limit)
segments = list(session.execute(stmt).scalars().all())
for seg in segments:
hum = _first_message_for_segment(session, segment_id=str(seg.id), role="human")
if not hum:
continue
ai = _first_message_for_segment(session, segment_id=str(seg.id), role="ai")
ln = DialogueLineage.for_single_turn(
conversation_id=str(seg.conversation_id),
user_message_id=str(hum.id),
assistant_message_id=str(ai.id) if ai else None,
segment_ids=[str(seg.id)],
)
seg.user_message_id = str(hum.id)
seg.lineage_json = ln.model_dump(mode="json")
n += 1
session.commit()
print(f"updated_segments={n} scanned={len(segments)}")
finally:
session.close()
if __name__ == "__main__":
main()