fix: 修复报告摘要生成并稳定默认端口

This commit is contained in:
meijiali
2026-07-03 12:20:59 +08:00
parent 5c34ce75b6
commit 0a3477cc44
10 changed files with 230 additions and 20 deletions
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.models import Comment, ContentItem, Hotspot, Report, Task
from app.schemas import CreateTaskRequest
from app.services.report_service import DEFAULT_SUMMARY
from app.services.task_service import create_task, run_task
from tests.helpers import make_test_client
@@ -28,7 +29,11 @@ class FakeXhsPlatform:
return [CommentData(source_comment_id="c1", content="评论一", like_count=5, raw_data={"id": "c1"})]
ai_prompts: list[str] = []
def successful_ai_response(prompt: str) -> str:
ai_prompts.append(prompt)
comments = json.loads(prompt[prompt.index("[") :])
comment_id = comments[0]["comment_id"]
return json.dumps(
@@ -38,9 +43,14 @@ def successful_ai_response(prompt: str) -> str:
def test_xiaohongshu_task_flow_persists_hotspots_items_and_comments(monkeypatch):
ai_prompts.clear()
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
monkeypatch.setattr("app.services.task_service.build_ai_requester", lambda: successful_ai_response)
summary_provider = lambda metrics, typical, *, word_limit=200: (
ai_prompts.append(f"只返回一段中文总结 word_limit={word_limit} sample={metrics['sample_count']}")
or "这是一段真实 AI 报告摘要。"
)
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (successful_ai_response, summary_provider))
with Session(engine) as session:
task = create_task(
@@ -66,14 +76,21 @@ def test_xiaohongshu_task_flow_persists_hotspots_items_and_comments(monkeypatch)
assert comment.sentiment == "positive"
assert comment.labels == '["认可"]'
assert comment.reason == "喜欢"
assert session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "item")) is not None
assert session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "hotspot")) is not None
item_report = session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "item"))
hotspot_report = session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "hotspot"))
assert item_report is not None
assert hotspot_report is not None
assert item_report.summary == "这是一段真实 AI 报告摘要。"
assert hotspot_report.summary == "这是一段真实 AI 报告摘要。"
assert DEFAULT_SUMMARY not in item_report.markdown_content
assert DEFAULT_SUMMARY not in hotspot_report.markdown_content
assert any("只返回一段中文总结" in prompt for prompt in ai_prompts)
def test_xiaohongshu_task_flow_marks_comments_failed_when_ai_parse_fails(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
monkeypatch.setattr("app.services.task_service.build_ai_requester", lambda: (lambda _prompt: "not-json"))
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (lambda _prompt: "not-json", None))
monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
with Session(engine) as session:
@@ -107,7 +124,7 @@ def test_xiaohongshu_task_flow_keeps_item_success_when_ai_request_raises(monkeyp
def failing_requester(_prompt):
raise RuntimeError("ai unauthorized")
monkeypatch.setattr("app.services.task_service.build_ai_requester", lambda: failing_requester)
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (failing_requester, None))
with Session(engine) as session:
task = create_task(
+52
View File
@@ -5,10 +5,12 @@ from app.services.ai_service import (
AIAnalysisResult,
OpenAICompatibleAIClient,
analyze_comments_with_retry,
build_report_summary_prompt,
build_comment_prompt,
calculate_analysis_status,
parse_ai_comment_response,
)
from app.services.task_service import build_report_summary_provider
def test_build_comment_prompt_contains_ids_and_truncates_content():
@@ -20,6 +22,35 @@ def test_build_comment_prompt_contains_ids_and_truncates_content():
assert "" * 151 not in prompt
def test_build_report_summary_prompt_contains_stats_labels_and_truncated_typical_comments():
prompt = build_report_summary_prompt(
{
"sample_count": 2,
"sentiment": {
"positive": {"count": 1, "pct": 50.0},
"neutral": {"count": 0, "pct": 0.0},
"negative": {"count": 1, "pct": 50.0},
"unknown": {"count": 0, "pct": 0.0},
},
"top_labels": [{"name": "价格争议", "count": 2}],
},
{"positive": [{"content": "" * 200}], "negative": [{"content": "太贵"}]},
word_limit=200,
)
assert "只返回一段中文总结" in prompt
assert "样本评论数量:2" in prompt
assert "positive: 1 (50.0%)" in prompt
assert "价格争议: 2" in prompt
assert "" * 150 in prompt
assert "" * 151 not in prompt
assert "JSON Array" not in prompt
def test_report_summary_provider_is_disabled_without_real_ai_client():
assert build_report_summary_provider(client=None) is None
def test_parse_ai_comment_response_validates_array_sentiment_labels_and_ids():
result = parse_ai_comment_response(
'[{"comment_id":"c1","sentiment":"positive","labels":["质量好"],"reason":"认可"}]',
@@ -94,6 +125,27 @@ def test_openai_compatible_client_posts_chat_completion_and_returns_message_cont
assert captured["authorization"] == "Bearer test-ai-key"
assert '"model":"test-model"' in captured["body"]
assert "prompt text" in captured["body"]
assert "请严格遵循用户指令输出。" in captured["body"]
def test_openai_compatible_client_accepts_custom_system_prompt():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = request.read().decode("utf-8")
return httpx.Response(200, json={"choices": [{"message": {"content": "报告摘要"}}]})
http_client = httpx.Client(transport=httpx.MockTransport(handler))
client = OpenAICompatibleAIClient(
base_url="https://ai.example.com",
api_key="test-ai-key",
model="test-model",
http_client=http_client,
)
assert client.request("总结 prompt", system_prompt="只返回一段中文总结,不使用 Markdown。") == "报告摘要"
assert "只返回一段中文总结" in captured["body"]
assert "JSON Array" not in captured["body"]
def test_openai_compatible_client_accepts_base_url_that_already_includes_v1():
+6
View File
@@ -11,6 +11,12 @@ def test_docker_compose_reads_real_env_file_not_example():
assert "- .env.example" not in compose_content
def test_docker_compose_uses_fixed_project_name_for_stable_port_owner():
compose_content = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "name: hot-comments-tool" in compose_content
def test_real_env_file_is_gitignored():
gitignore_lines = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
+36
View File
@@ -118,3 +118,39 @@ def test_douyin_maps_null_comments_as_empty_list():
payload = {"data": {"comments": None}}
assert platform.map_comments(payload, limit=10) == []
class RecordingDouyinClient:
def __init__(self):
self.requests = []
def get(self, path, *, params=None):
self.requests.append((path, params))
cursor = params["cursor"]
if cursor == 0:
return {
"data": {
"comments": [{"cid": f"c-{index}", "text": f"评论 {index}"} for index in range(20)],
"cursor": 20,
"has_more": 1,
}
}
return {
"data": {
"comments": [{"cid": f"c-{index}", "text": f"评论 {index}"} for index in range(20, 55)],
"cursor": 55,
"has_more": 0,
}
}
def test_douyin_fetch_comments_paginates_until_limit():
client = RecordingDouyinClient()
platform = DouyinPlatform(client=client)
comments = platform.fetch_comments("aweme-1", limit=50)
assert len(comments) == 50
assert comments[0].source_comment_id == "c-0"
assert comments[-1].source_comment_id == "c-49"
assert [request[1]["cursor"] for request in client.requests] == [0, 20]
+10
View File
@@ -1,6 +1,7 @@
from concurrent.futures import ThreadPoolExecutor
from app.services import task_service
from app.config import Settings
def test_task_executor_is_single_worker_thread_pool():
@@ -10,3 +11,12 @@ def test_task_executor_is_single_worker_thread_pool():
def test_run_task_entrypoint_is_synchronous_function():
assert task_service.inspect.iscoroutinefunction(task_service.run_task) is False
def test_ai_dependencies_do_not_generate_report_summary_provider_without_ai_config(monkeypatch):
monkeypatch.setattr("app.services.task_service.get_settings", lambda: Settings(_env_file=None))
requester, summary_provider = task_service.build_ai_dependencies()
assert requester("prompt") == "[]"
assert summary_provider is None