chanpinhsd/backend/server.py
lidf ec8eaa0b36 feat: 独立后端(共享 VOC 数据层 + 自有分析存储)
- backend/server.py: FastAPI 端口 8093
- backend/db.py: 双库设计(案例 DB 读写 + VOC DB 只读)
- backend/tools/ude_extract.py: UDE 转写 + 向量聚类
- backend/prompts/voc_to_ude.txt: TOC 7条规范约束
- 已部署至 /opt/apps/mafia-proposal/ (systemd)
- Nginx /copaw/mafia/api/ 代理已配置
2026-04-07 18:13:19 +08:00

218 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
黑手党提案 — 独立后端
FastAPI 服务,端口 8093。
数据来源:只读访问共享 VOC 数据层。
分析结果:存自己的案例 DB。
"""
import os
import logging
from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from dotenv import load_dotenv
load_dotenv()
from db import (
get_case_conn, get_voc_conn, init_case_db,
list_cases as _list_cases, list_voc_researches as _list_voc_researches,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("mafia")
app = FastAPI(title="黑手党提案后端", version="1.0.0", description="独立后端:共享 VOC 数据层 + 自有分析存储")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ═══════════ Models ═══════════
class CreateCaseRequest(BaseModel):
brandName: str
category: str = ""
focusProduct: str = ""
competitors: str = "[]"
vocResearchId: str = None
class LinkVocRequest(BaseModel):
vocResearchId: str
# ═══════════ 案例管理 ═══════════
@app.post("/api/cases")
async def create_case(req: CreateCaseRequest):
case_id = init_case_db(
brand_name=req.brandName,
category=req.category,
focus_product=req.focusProduct,
competitors=req.competitors,
voc_research_id=req.vocResearchId,
)
return {"caseId": case_id}
@app.get("/api/cases")
async def get_cases():
return _list_cases()
@app.get("/api/cases/{case_id}")
async def get_case(case_id: str):
try:
with get_case_conn(case_id) as conn:
card = conn.execute("SELECT * FROM case_card LIMIT 1").fetchone()
ude_count = conn.execute("SELECT count(*) FROM ude_sentences").fetchone()[0]
cluster_count = conn.execute("SELECT count(*) FROM ude_clusters").fetchone()[0]
if not card:
raise HTTPException(404, "案例不存在")
return {"caseId": case_id, **dict(card), "udeCount": ude_count, "clusterCount": cluster_count}
except FileNotFoundError:
raise HTTPException(404, "案例不存在")
@app.delete("/api/cases/{case_id}")
async def delete_case(case_id: str):
from db import DATA_DIR
path = DATA_DIR / f"{case_id}.db"
if path.exists():
path.unlink()
return {"deleted": True}
raise HTTPException(404, "案例不存在")
# ═══════════ VOC 关联 ═══════════
@app.post("/api/cases/{case_id}/link-voc")
async def link_voc(case_id: str, req: LinkVocRequest):
"""关联 VOC 研究 ID验证 VOC 研究存在后再写入)"""
try:
with get_voc_conn(req.vocResearchId) as voc:
count = voc.execute(
"SELECT count(*) FROM comments WHERE length(text) > 10 "
).fetchone()[0]
except FileNotFoundError as e:
raise HTTPException(404, str(e))
try:
with get_case_conn(case_id) as conn:
conn.execute("UPDATE case_card SET voc_research_id = ?", (req.vocResearchId,))
conn.commit()
except FileNotFoundError:
raise HTTPException(404, "案例不存在")
return {"linked": True, "vocCommentCount": count}
@app.get("/api/voc/researches")
async def get_voc_researches():
return _list_voc_researches()
@app.get("/api/cases/{case_id}/voc-comments")
async def get_voc_comments(case_id: str, page: int = 1, pageSize: int = 50):
"""从共享 VOC 数据层只读获取原始评论"""
try:
with get_case_conn(case_id) as conn:
card = conn.execute("SELECT voc_research_id FROM case_card LIMIT 1").fetchone()
except FileNotFoundError:
raise HTTPException(404, "案例不存在")
if not card or not card["voc_research_id"]:
raise HTTPException(400, "未关联 VOC 研究")
try:
with get_voc_conn(card["voc_research_id"]) as voc:
total = voc.execute(
"SELECT count(*) FROM comments WHERE length(text) > 10 "
).fetchone()[0]
rows = voc.execute("""
SELECT id, platform, text, like_count, published_at
FROM comments WHERE length(text) > 10
ORDER BY like_count DESC
LIMIT ? OFFSET ?
""", (pageSize, (page - 1) * pageSize)).fetchall()
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return {"total": total, "page": page, "items": [dict(r) for r in rows]}
# ═══════════ UDE 分析 ═══════════
@app.post("/api/cases/{case_id}/ude/extract")
async def extract_ude(case_id: str, limit: int = Query(0)):
from tools.ude_extract import run_ude_extraction
try:
result = await run_ude_extraction(case_id, limit)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return result
@app.post("/api/cases/{case_id}/ude/cluster")
async def cluster_ude(
case_id: str,
eps: float = Query(0.25),
minSamples: int = Query(3),
x_dashscope_key: str = Header(None),
):
from tools.ude_extract import run_clustering
key = x_dashscope_key or os.getenv("DASHSCOPE_API_KEY", "")
try:
result = run_clustering(case_id, eps, minSamples, dashscope_key=key)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return result
@app.get("/api/cases/{case_id}/ude/clusters")
async def get_clusters(case_id: str):
try:
with get_case_conn(case_id) as conn:
clusters = conn.execute(
"SELECT * FROM ude_clusters ORDER BY coverage DESC"
).fetchall()
except FileNotFoundError:
raise HTTPException(404, "案例不存在")
return [dict(r) for r in clusters]
@app.get("/api/cases/{case_id}/ude/coverage")
async def get_coverage(case_id: str):
from tools.ude_extract import run_coverage_scan
try:
result = run_coverage_scan(case_id)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return result
# ═══════════ 健康检查 ═══════════
@app.get("/api/health")
async def health():
from db import VOC_DATA_DIR, DATA_DIR
return {
"status": "ok",
"vocDataDir": str(VOC_DATA_DIR),
"vocDataExists": VOC_DATA_DIR.exists(),
"caseDataDir": str(DATA_DIR),
}
# ═══════════ 启动 ═══════════
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", "8093"))
uvicorn.run(app, host="0.0.0.0", port=port)