chore: prepare yanting monorepo handoff
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import AudioAsset, DisplayModule, Institution, RelatedNews, Report
|
||||
|
||||
|
||||
MODULE_META: dict[str, dict[str, Any]] = {
|
||||
"basic_info": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
|
||||
"executive_overview": {"layer": "p0", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
|
||||
"core_insights": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
|
||||
"key_data": {"layer": "p0", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
|
||||
"source_compliance": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
|
||||
"differentiated_view": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
|
||||
"weaknesses": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
|
||||
"timeline": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
|
||||
"study_guide": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
|
||||
"related_sources": {"layer": "p1", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
|
||||
"structure_graph": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
|
||||
"infographic": {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
|
||||
"audio": {"layer": "p2", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
|
||||
"research_discovery": {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
|
||||
"institution": {"layer": "p0", "render_mode": "inline", "has_detail_page": False, "is_publish_blocking": False, "requires_human_review": False},
|
||||
}
|
||||
|
||||
|
||||
def loads_json(value: str | None, default: Any) -> Any:
|
||||
if not value:
|
||||
return default
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def iso(value: Any) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def institution_public(inst: Institution, *, detail: bool = False) -> dict[str, Any]:
|
||||
data = {
|
||||
"institution_id": inst.institution_id,
|
||||
"name_cn": inst.name_cn,
|
||||
"name_en": inst.name_en,
|
||||
"institution_type": inst.institution_type,
|
||||
"source_tier": inst.source_tier,
|
||||
"website_url": inst.website_url,
|
||||
"covered_topics": loads_json(inst.covered_topics, []),
|
||||
"report_count": inst.report_count,
|
||||
"latest_report_at": iso(inst.latest_report_at),
|
||||
"credibility_note": inst.credibility_note,
|
||||
}
|
||||
if detail:
|
||||
data["intro_cn"] = inst.intro_cn
|
||||
return data
|
||||
|
||||
|
||||
def institution_card(inst: Institution) -> dict[str, Any]:
|
||||
return {
|
||||
"institution_id": inst.institution_id,
|
||||
"name_cn": inst.name_cn,
|
||||
"name_en": inst.name_en,
|
||||
"source_tier": inst.source_tier,
|
||||
}
|
||||
|
||||
|
||||
def report_card(report: Report, inst: Institution) -> dict[str, Any]:
|
||||
return {
|
||||
"report_id": report.report_id,
|
||||
"title_cn": report.title_cn,
|
||||
"subtitle_cn": report.subtitle_cn or "",
|
||||
"one_liner": report.one_liner,
|
||||
"institution": institution_card(inst),
|
||||
"topics": loads_json(report.topics, []),
|
||||
"released_at": iso(report.released_at),
|
||||
"has_audio": report.has_audio,
|
||||
"interpretation_label": report.interpretation_label,
|
||||
"source_tier": report.source_tier,
|
||||
"cache_version": report.cache_version,
|
||||
}
|
||||
|
||||
|
||||
def module_payload(module: DisplayModule) -> dict[str, Any]:
|
||||
meta = MODULE_META.get(module.type, {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False})
|
||||
envelope = loads_json(module.content, {})
|
||||
render_mode = meta["render_mode"]
|
||||
content = None
|
||||
preview = None
|
||||
if render_mode == "inline":
|
||||
content = envelope.get("content", envelope)
|
||||
else:
|
||||
preview = envelope.get("preview", {})
|
||||
return {
|
||||
"module_id": module.module_id,
|
||||
"type": module.type,
|
||||
"layer": meta["layer"],
|
||||
"render_mode": render_mode,
|
||||
"has_detail_page": meta["has_detail_page"],
|
||||
"is_publish_blocking": meta["is_publish_blocking"],
|
||||
"requires_human_review": meta["requires_human_review"],
|
||||
"sort_order": module.sort_order,
|
||||
"title_cn": module.title_cn,
|
||||
"content": content,
|
||||
"preview": preview,
|
||||
"content_ref": module.content_ref,
|
||||
"content_etag": module.content_etag,
|
||||
}
|
||||
|
||||
|
||||
class CatalogService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def _published_report_query(self) -> Select[tuple[Report, Institution]]:
|
||||
return (
|
||||
select(Report, Institution)
|
||||
.join(Institution, Report.institution_id == Institution.institution_id)
|
||||
.where(Report.display_status == "published", Institution.status == "active")
|
||||
.order_by(Report.released_at.desc(), Report.report_id)
|
||||
)
|
||||
|
||||
async def report_cards(
|
||||
self,
|
||||
*,
|
||||
topic: str | None = None,
|
||||
institution_id: str | None = None,
|
||||
has_audio: bool | None = None,
|
||||
source_tier: str | None = None,
|
||||
q: str | None = None,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
stmt = await self._published_report_query()
|
||||
if topic:
|
||||
stmt = stmt.where(Report.topics.like(f"%{topic}%"))
|
||||
if institution_id:
|
||||
stmt = stmt.where(Report.institution_id == institution_id)
|
||||
if has_audio is not None:
|
||||
stmt = stmt.where(Report.has_audio == has_audio)
|
||||
if source_tier:
|
||||
stmt = stmt.where(Report.source_tier == source_tier)
|
||||
if q:
|
||||
stmt = stmt.where(Report.title_cn.like(f"%{q}%"))
|
||||
stmt = stmt.limit(min(max(page_size, 1), 50))
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return {
|
||||
"items": [report_card(report, inst) for report, inst in rows],
|
||||
"page": {"next_cursor": None, "has_more": False},
|
||||
"cache_version": "feed:recommended:seed:v1",
|
||||
}
|
||||
|
||||
async def report_detail(self, report_id: str) -> dict[str, Any]:
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(Report, Institution)
|
||||
.join(Institution, Report.institution_id == Institution.institution_id)
|
||||
.where(Report.report_id == report_id, Report.display_status == "published", Institution.status == "active")
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "REPORT_NOT_FOUND", "message": "报告不存在或未发布。"}})
|
||||
report, inst = row
|
||||
modules = (
|
||||
await self.session.execute(
|
||||
select(DisplayModule)
|
||||
.where(DisplayModule.report_id == report_id, DisplayModule.status == "published")
|
||||
.order_by(DisplayModule.sort_order)
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"report_id": report.report_id,
|
||||
"title_cn": report.title_cn,
|
||||
"subtitle_cn": report.subtitle_cn or "",
|
||||
"original_title": report.original_title,
|
||||
"one_liner": report.one_liner,
|
||||
"institution": institution_public(inst, detail=True),
|
||||
"source": {
|
||||
"source_url": report.source_url,
|
||||
"source_note": report.source_note,
|
||||
"source_tier": report.source_tier,
|
||||
"published_at": iso(report.published_at),
|
||||
},
|
||||
"topics": loads_json(report.topics, []),
|
||||
"has_audio": report.has_audio,
|
||||
"interpretation_label": report.interpretation_label,
|
||||
"risk_disclaimer": report.risk_disclaimer,
|
||||
"released_at": iso(report.released_at),
|
||||
"cache_version": report.cache_version,
|
||||
"modules": [module_payload(module) for module in modules],
|
||||
}
|
||||
|
||||
async def module_detail(self, report_id: str, module_id: str) -> dict[str, Any]:
|
||||
report = (
|
||||
await self.session.execute(select(Report).where(Report.report_id == report_id, Report.display_status == "published"))
|
||||
).scalar_one_or_none()
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "REPORT_NOT_FOUND", "message": "报告不存在或未发布。"}})
|
||||
module = (
|
||||
await self.session.execute(
|
||||
select(DisplayModule).where(DisplayModule.report_id == report_id, DisplayModule.module_id == module_id, DisplayModule.status == "published")
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if module is None:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "MODULE_HIDDEN", "message": "模块隐藏或不可见。"}})
|
||||
envelope = loads_json(module.content, {})
|
||||
content = envelope.get("full") or envelope.get("content") or envelope
|
||||
return {
|
||||
"module_id": module.module_id,
|
||||
"type": module.type,
|
||||
"title_cn": module.title_cn,
|
||||
"content": content,
|
||||
"content_etag": module.content_etag,
|
||||
"cache_version": report.cache_version,
|
||||
}
|
||||
|
||||
async def institutions(self, *, topic: str | None = None, source_tier: str | None = None, page_size: int = 20) -> dict[str, Any]:
|
||||
stmt = select(Institution).where(Institution.status == "active").order_by(Institution.source_tier, Institution.name_cn).limit(min(max(page_size, 1), 50))
|
||||
if topic:
|
||||
stmt = stmt.where(Institution.covered_topics.like(f"%{topic}%"))
|
||||
if source_tier:
|
||||
stmt = stmt.where(Institution.source_tier == source_tier)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return {"items": [institution_public(inst) for inst in rows], "page": {"next_cursor": None, "has_more": False}}
|
||||
|
||||
async def institution_detail(self, institution_id: str) -> dict[str, Any]:
|
||||
inst = (await self.session.execute(select(Institution).where(Institution.institution_id == institution_id, Institution.status == "active"))).scalar_one_or_none()
|
||||
if inst is None:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "INSTITUTION_NOT_FOUND", "message": "机构不存在。"}})
|
||||
reports = await self.report_cards(institution_id=institution_id, page_size=5)
|
||||
detail = institution_public(inst, detail=True)
|
||||
detail["latest_report"] = reports["items"][0] if reports["items"] else None
|
||||
detail["recent_reports"] = reports["items"]
|
||||
return detail
|
||||
|
||||
async def listen_items(self, *, page_size: int = 20) -> dict[str, Any]:
|
||||
stmt = (
|
||||
select(AudioAsset, Report, Institution)
|
||||
.join(Report, AudioAsset.report_id == Report.report_id)
|
||||
.join(Institution, Report.institution_id == Institution.institution_id)
|
||||
.where(AudioAsset.status == "published", Report.display_status == "published")
|
||||
.order_by(Report.released_at.desc(), AudioAsset.audio_id)
|
||||
.limit(min(max(page_size, 1), 50))
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
items = [
|
||||
{
|
||||
"audio_id": audio.audio_id,
|
||||
"title_cn": audio.title_cn,
|
||||
"duration_sec": audio.duration_sec,
|
||||
"report_id": report.report_id,
|
||||
"report_title_cn": report.title_cn,
|
||||
"institution": institution_card(inst),
|
||||
"released_at": iso(report.released_at),
|
||||
"cache_version": report.cache_version,
|
||||
}
|
||||
for audio, report, inst in rows
|
||||
]
|
||||
return {"items": items, "page": {"next_cursor": None, "has_more": False}, "cache_version": "listen:seed:v1"}
|
||||
|
||||
async def seed_counts(self) -> dict[str, int]:
|
||||
models = {
|
||||
"institutions": Institution,
|
||||
"reports": Report,
|
||||
"audio_assets": AudioAsset,
|
||||
"display_modules": DisplayModule,
|
||||
"related_news": RelatedNews,
|
||||
}
|
||||
counts = {}
|
||||
for name, model in models.items():
|
||||
counts[name] = await self.session.scalar(select(func.count()).select_from(model)) or 0
|
||||
return counts
|
||||
Reference in New Issue
Block a user