feat: 异步沉淀主题摘要和成长档案

This commit is contained in:
2026-08-03 11:35:58 +08:00
parent a3946013f5
commit d2e08ab36c
24 changed files with 825 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@@ -10,11 +10,12 @@ from app.models import Base
from app.models.ai_config import SystemConfig
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan
from app.models.growth import GrowthProfileRevision, UserGrowthProfile
from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile
from app.models.user import User
from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService
from app.services.growth_profile_service import GrowthProfileService, _parse_summary_json
from app.services.rag_service import PromptService
from app.services.topic_settlement_worker import TopicSettlementWorker
def _db() -> Session:
@@ -27,7 +28,7 @@ def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def test_finish_topic_generates_summary_and_updates_growth_profile_for_enabled_plan():
def test_finish_topic_enqueues_summary_and_worker_updates_growth_profile_for_enabled_plan():
with _db() as db:
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
db.add(EntitlementPlan(id=10, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, enable_growth_profile=1, status=1))
@@ -58,13 +59,114 @@ def test_finish_topic_generates_summary_and_updates_growth_profile_for_enabled_p
result = GrowthProfileService.finish_active_topic(db, user=user, session=session)
assert result["topic"]["status"] == "completed"
assert result["summary"]["summary"]
assert result["profile"] is not None
assert "最近主题" in result["profile"]["profileText"]
assert result["settlementStatus"] == "pending"
assert result["summary"]["summary"] == ""
assert result["profile"] is None
assert db.query(UserGrowthProfile).filter_by(user_id=1).count() == 0
summary_id = TopicSettlementWorker.claim_next(db, worker_id="test-worker", now=_now())
assert summary_id == result["summary"]["id"]
completed = TopicSettlementWorker.execute_claimed(db, summary_id=summary_id, worker_id="test-worker")
assert completed is not None
assert completed.status == "success"
assert completed.summary
profile = db.query(UserGrowthProfile).filter_by(user_id=1).one()
assert "最近主题" in profile.profile_text
assert db.query(UserGrowthProfile).filter_by(user_id=1).count() == 1
assert db.query(GrowthProfileRevision).filter_by(user_id=1).count() == 1
def test_finish_topic_is_idempotent_and_does_not_call_model_in_request(monkeypatch):
with _db() as db:
now = _now()
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=1, user_id=1, title="测试", message_count=1, last_message_at=now, is_deleted=0)
topic = TopicSession(
id=1,
user_id=1,
chat_session_id=1,
title="测试主题",
core_question="我想沉淀一下",
status="active",
message_count=1,
started_at=now,
)
message = ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我想沉淀一下", created_at=now)
db.add_all([user, session, topic, message])
db.commit()
monkeypatch.setattr(
"app.services.tracked_generation_service.TrackedGenerationService.generate",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("结束主题接口不应调用模型")),
)
first = GrowthProfileService.finish_active_topic(db, user=user, session=session)
second = GrowthProfileService.finish_active_topic(db, user=user, session=session)
assert first["summary"]["id"] == second["summary"]["id"]
assert first["settlementStatus"] == "pending"
assert second["settlementStatus"] == "pending"
assert db.query(TopicSummary).count() == 1
def test_failed_topic_settlement_is_retried(monkeypatch):
with _db() as db:
now = _now()
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=1, user_id=1, title="测试", message_count=1, last_message_at=now, is_deleted=0)
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="测试", core_question="测试", status="active", started_at=now)
message = ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="测试", created_at=now)
db.add_all([user, session, topic, message])
db.commit()
result = GrowthProfileService.finish_active_topic(db, user=user, session=session)
monkeypatch.setattr(
GrowthProfileService,
"process_topic_settlement",
staticmethod(lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("模型暂时不可用"))),
)
summary_id = TopicSettlementWorker.claim_next(db, worker_id="retry-worker", now=_now())
retried = TopicSettlementWorker.execute_claimed(db, summary_id=summary_id, worker_id="retry-worker")
assert summary_id == result["summary"]["id"]
assert retried is not None
assert retried.status == "pending"
assert retried.attempt_count == 1
assert retried.next_run_at is not None
assert "模型暂时不可用" in (retried.error_message or "")
def test_stale_topic_settlement_is_recovered_after_restart():
with _db() as db:
now = _now()
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=1, user_id=1, title="测试", message_count=1, last_message_at=now, is_deleted=0)
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="测试", core_question="测试", status="completed", started_at=now, ended_at=now)
summary = TopicSummary(
id=1,
topic_session_id=1,
user_id=1,
summary="",
status="running",
attempt_count=1,
max_attempts=3,
locked_by="stopped-worker",
locked_at=now - timedelta(minutes=31),
)
db.add_all([user, session, topic, summary])
db.commit()
recovered = TopicSettlementWorker.recover_stale_jobs(db, now=now)
db.commit()
db.refresh(summary)
assert recovered == 1
assert summary.status == "pending"
assert summary.locked_by is None
assert summary.next_run_at == now
assert "自动恢复" in (summary.error_message or "")
def test_prompt_can_include_growth_profile_context_without_replacing_knowledge_context():
with _db() as db:
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
@@ -84,3 +186,27 @@ def test_prompt_can_include_growth_profile_context_without_replacing_knowledge_c
assert "[长期成长档案]" in content
assert "表达障碍" in content
assert "[本轮可靠知识上下文]" in content
def test_summary_json_parser_ignores_model_reasoning_and_uses_final_json():
raw = """
<think>
先分析一下,并给出一个未完成草稿:
```json
{"profileText": "不应采用的草稿"}
```
</think>
```json
{
"profileText": "只保留最终成长档案",
"commonEmotions": ["紧张"],
"recentProgress": ["开始观察身体感受"]
}
```
"""
parsed = _parse_summary_json(raw)
assert parsed is not None
assert parsed["profileText"] == "只保留最终成长档案"
assert "think" not in str(parsed).lower()