Przeglądaj źródła

删除python后台

liuchengsen 1 miesiąc temu
rodzic
commit
6257e0bf44

+ 0 - 0
backend-python/app/__init__.py


+ 0 - 0
backend-python/app/api/__init__.py


+ 0 - 35
backend-python/app/api/admin.py

@@ -1,35 +0,0 @@
-from fastapi import APIRouter, Depends, Query
-
-from app.core.security import get_current_user
-
-router = APIRouter(prefix="/admin", tags=["管理"])
-
-
-@router.get("/stats")
-async def get_stats(user: dict = Depends(get_current_user)):
-    return {
-        "total_queries": 0,
-        "total_users": 0,
-        "avg_response_time_ms": 0,
-        "top_drugs": [],
-        "daily_queries": [],
-    }
-
-
-@router.get("/feedback/list")
-async def list_feedback(
-    page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=100),
-    user: dict = Depends(get_current_user),
-):
-    return {"items": [], "total": 0, "page": page, "page_size": page_size}
-
-
-@router.get("/knowledge/status")
-async def knowledge_status(user: dict = Depends(get_current_user)):
-    return {
-        "total_drugs": 0,
-        "total_chunks": 0,
-        "last_update": None,
-        "embedding_model": "BAAI/bge-m3",
-    }

+ 0 - 393
backend-python/app/api/admin_knowledge.py

@@ -1,393 +0,0 @@
-from typing import Optional
-import json
-import csv
-from io import StringIO
-from datetime import datetime, timezone
-
-from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
-from pydantic import BaseModel, Field
-
-from app.core.security import get_current_user
-from app.core.config import get_settings
-
-settings = get_settings()
-
-router = APIRouter(prefix="/admin/knowledge", tags=["管理-知识库"])
-
-
-# ============================================
-# Schemas
-# ============================================
-
-class DrugCreate(BaseModel):
-    drug_id: str
-    name: str
-    name_en: Optional[str] = None
-    pinyin: Optional[str] = None
-    category: Optional[str] = None
-    subcategory: Optional[str] = None
-    approval_number: Optional[str] = None
-    sections: Optional[dict] = None
-    source_version: Optional[str] = None
-    source_volume: Optional[str] = None
-    source_page: Optional[str] = None
-
-
-class DrugUpdate(BaseModel):
-    name: Optional[str] = None
-    name_en: Optional[str] = None
-    pinyin: Optional[str] = None
-    category: Optional[str] = None
-    subcategory: Optional[str] = None
-    approval_number: Optional[str] = None
-    sections: Optional[dict] = None
-    source_version: Optional[str] = None
-    source_volume: Optional[str] = None
-    source_page: Optional[str] = None
-    is_active: Optional[bool] = None
-
-
-class KnowledgePointCreate(BaseModel):
-    point_id: str
-    subject: str
-    chapter_id: str
-    chapter_name: str
-    title: str
-    content: str
-    difficulty: int = Field(default=1, ge=1, le=5)
-    frequency: Optional[str] = None
-    related_drugs: Optional[list] = None
-    source: Optional[str] = None
-
-
-class KnowledgePointUpdate(BaseModel):
-    chapter_name: Optional[str] = None
-    title: Optional[str] = None
-    content: Optional[str] = None
-    difficulty: Optional[int] = Field(default=None, ge=1, le=5)
-    frequency: Optional[str] = None
-    related_drugs: Optional[list] = None
-    source: Optional[str] = None
-
-
-class QuestionCreate(BaseModel):
-    question_id: str
-    question_type: str
-    subject: str
-    chapter_id: str
-    difficulty: int = Field(default=1, ge=1, le=5)
-    content: str
-    options: list[str]
-    answer: str
-    explanation: str
-    knowledge_point_ids: Optional[list[str]] = None
-    source: Optional[str] = None
-    frequency: Optional[str] = None
-
-
-class QuestionUpdate(BaseModel):
-    question_type: Optional[str] = None
-    subject: Optional[str] = None
-    chapter_id: Optional[str] = None
-    difficulty: Optional[int] = Field(default=None, ge=1, le=5)
-    content: Optional[str] = None
-    options: Optional[list[str]] = None
-    answer: Optional[str] = None
-    explanation: Optional[str] = None
-    knowledge_point_ids: Optional[list[str]] = None
-    source: Optional[str] = None
-    frequency: Optional[str] = None
-    audited: Optional[bool] = None
-
-
-class ReindexRequest(BaseModel):
-    collection: str = Field(default="drug_entries", description="drug_entries / exam_knowledge")
-    drug_ids: Optional[list[str]] = Field(default=None, description="指定药品ID,不填则全量重建")
-
-
-class ImportRequest(BaseModel):
-    type: str = Field(..., description="drug / knowledge_point / question")
-    format: str = Field(default="json", description="json / csv")
-    data: str = Field(..., description="JSON/CSV 内容字符串")
-
-
-class BatchAuditRequest(BaseModel):
-    question_ids: list[str]
-    audited: bool = True
-
-
-# ============================================
-# 药品管理 CRUD
-# ============================================
-
-@router.get("/drugs")
-async def list_drugs(
-    keyword: Optional[str] = Query(None, description="药品名称搜索"),
-    category: Optional[str] = Query(None, description="药品分类"),
-    page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=100),
-    user: dict = Depends(get_current_user),
-):
-    return {
-        "items": [],
-        "total": 0,
-        "page": page,
-        "page_size": page_size,
-        "message": "数据入库后可用",
-    }
-
-
-@router.get("/drugs/{drug_id}")
-async def get_drug(drug_id: str, user: dict = Depends(get_current_user)):
-    return {"message": "数据入库后可用", "drug_id": drug_id}
-
-
-@router.post("/drugs")
-async def create_drug(
-    drug: DrugCreate,
-    user: dict = Depends(get_current_user),
-):
-    return {"message": "已创建", "drug_id": drug.drug_id}
-
-
-@router.put("/drugs/{drug_id}")
-async def update_drug(
-    drug_id: str,
-    drug: DrugUpdate,
-    user: dict = Depends(get_current_user),
-):
-    return {"message": "已更新", "drug_id": drug_id}
-
-
-@router.delete("/drugs/{drug_id}")
-async def delete_drug(drug_id: str, user: dict = Depends(get_current_user)):
-    return {"message": "已标记删除", "drug_id": drug_id}
-
-
-@router.post("/drugs/import")
-async def import_drugs(
-    req: ImportRequest,
-    user: dict = Depends(get_current_user),
-):
-    count = 0
-    if req.format == "json":
-        try:
-            items = json.loads(req.data)
-            count = len(items) if isinstance(items, list) else 0
-        except json.JSONDecodeError as e:
-            raise HTTPException(status_code=400, detail=f"JSON 解析失败: {str(e)}")
-    elif req.format == "csv":
-        try:
-            reader = csv.DictReader(StringIO(req.data))
-            count = sum(1 for _ in reader)
-        except Exception as e:
-            raise HTTPException(status_code=400, detail=f"CSV 解析失败: {str(e)}")
-
-    return {
-        "message": "导入任务已提交",
-        "total": count,
-        "success": 0,
-        "failed": 0,
-        "note": "Phase 2 实现异步批量写入 + 向量化",
-    }
-
-
-@router.post("/drugs/reindex")
-async def reindex_drugs(
-    req: ReindexRequest,
-    user: dict = Depends(get_current_user),
-):
-    return {
-        "message": "重建索引任务已提交",
-        "collection": req.collection,
-        "drug_ids": req.drug_ids,
-        "status": "pending",
-        "note": "Phase 2 实现:重新 chunk → 向量化 → 写入 Milvus",
-    }
-
-
-# ============================================
-# 知识点管理 CRUD
-# ============================================
-
-@router.get("/knowledge-points")
-async def list_knowledge_points(
-    subject: Optional[str] = Query(None),
-    chapter_id: Optional[str] = Query(None),
-    difficulty: Optional[int] = Query(None, ge=1, le=5),
-    frequency: Optional[str] = Query(None),
-    page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=100),
-    user: dict = Depends(get_current_user),
-):
-    return {
-        "items": [],
-        "total": 0,
-        "page": page,
-        "page_size": page_size,
-    }
-
-
-@router.get("/knowledge-points/{point_id}")
-async def get_knowledge_point(point_id: str, user: dict = Depends(get_current_user)):
-    return {"message": "数据入库后可用", "point_id": point_id}
-
-
-@router.post("/knowledge-points")
-async def create_knowledge_point(
-    kp: KnowledgePointCreate,
-    user: dict = Depends(get_current_user),
-):
-    return {"message": "已创建", "point_id": kp.point_id}
-
-
-@router.put("/knowledge-points/{point_id}")
-async def update_knowledge_point(
-    point_id: str,
-    kp: KnowledgePointUpdate,
-    user: dict = Depends(get_current_user),
-):
-    return {"message": "已更新", "point_id": point_id}
-
-
-@router.delete("/knowledge-points/{point_id}")
-async def delete_knowledge_point(point_id: str, user: dict = Depends(get_current_user)):
-    return {"message": "已删除", "point_id": point_id}
-
-
-@router.post("/knowledge-points/import")
-async def import_knowledge_points(
-    req: ImportRequest,
-    user: dict = Depends(get_current_user),
-):
-    count = 0
-    try:
-        items = json.loads(req.data)
-        count = len(items) if isinstance(items, list) else 0
-    except json.JSONDecodeError as e:
-        raise HTTPException(status_code=400, detail=f"JSON 解析失败: {str(e)}")
-
-    return {
-        "message": "导入任务已提交",
-        "total": count,
-        "success": 0,
-        "failed": 0,
-    }
-
-
-# ============================================
-# 题库管理 CRUD
-# ============================================
-
-@router.get("/questions")
-async def list_questions(
-    subject: Optional[str] = Query(None),
-    chapter_id: Optional[str] = Query(None),
-    question_type: Optional[str] = Query(None),
-    difficulty: Optional[int] = Query(None, ge=1, le=5),
-    audited: Optional[bool] = Query(None),
-    page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=100),
-    user: dict = Depends(get_current_user),
-):
-    return {
-        "items": [],
-        "total": 0,
-        "page": page,
-        "page_size": page_size,
-    }
-
-
-@router.get("/questions/{question_id}")
-async def get_question(question_id: str, user: dict = Depends(get_current_user)):
-    return {"message": "数据入库后可用", "question_id": question_id}
-
-
-@router.post("/questions")
-async def create_question(
-    q: QuestionCreate,
-    user: dict = Depends(get_current_user),
-):
-    return {"message": "已创建", "question_id": q.question_id}
-
-
-@router.put("/questions/{question_id}")
-async def update_question(
-    question_id: str,
-    q: QuestionUpdate,
-    user: dict = Depends(get_current_user),
-):
-    return {"message": "已更新", "question_id": question_id}
-
-
-@router.delete("/questions/{question_id}")
-async def delete_question(question_id: str, user: dict = Depends(get_current_user)):
-    return {"message": "已删除", "question_id": question_id}
-
-
-@router.post("/questions/batch-audit")
-async def batch_audit_questions(
-    req: BatchAuditRequest,
-    user: dict = Depends(get_current_user),
-):
-    return {
-        "message": "批量审核完成",
-        "question_ids": req.question_ids,
-        "audited": req.audited,
-    }
-
-
-@router.post("/questions/import")
-async def import_questions(
-    req: ImportRequest,
-    user: dict = Depends(get_current_user),
-):
-    count = 0
-    try:
-        items = json.loads(req.data)
-        count = len(items) if isinstance(items, list) else 0
-    except json.JSONDecodeError as e:
-        raise HTTPException(status_code=400, detail=f"JSON 解析失败: {str(e)}")
-
-    return {
-        "message": "导入任务已提交",
-        "total": count,
-        "success": 0,
-        "failed": 0,
-    }
-
-
-@router.post("/questions/generate")
-async def ai_generate_questions(
-    point_id: str = Query(..., description="知识点ID"),
-    count: int = Query(10, ge=1, le=50),
-    question_type: str = Query("A", description="A/B/X"),
-    user: dict = Depends(get_current_user),
-):
-    return {
-        "message": "AI 出题任务已提交",
-        "point_id": point_id,
-        "count": count,
-        "question_type": question_type,
-        "status": "pending",
-        "note": "Phase 2 实现:LLM 根据知识点生成题目",
-    }
-
-
-# ============================================
-# 知识库状态与统计
-# ============================================
-
-@router.get("/stats")
-async def knowledge_stats(user: dict = Depends(get_current_user)):
-    return {
-        "drugs": {"total": 0, "active": 0, "with_chunks": 0},
-        "knowledge_points": {"total": 0, "by_subject": {}},
-        "questions": {"total": 0, "audited": 0, "pending": 0},
-        "vector_index": {
-            "status": "empty",
-            "collection": settings.milvus_collection,
-            "last_reindex": None,
-        },
-        "last_data_update": None,
-    }

+ 0 - 39
backend-python/app/api/auth.py

@@ -1,39 +0,0 @@
-from fastapi import APIRouter, HTTPException
-from pydantic import BaseModel
-import httpx
-from app.core.security import create_access_token
-from app.core.config import get_settings
-
-settings = get_settings()
-router = APIRouter(prefix="/auth", tags=["鉴权"])
-
-
-class WechatLoginRequest(BaseModel):
-    code: str
-
-
-class LoginResponse(BaseModel):
-    access_token: str
-    token_type: str = "bearer"
-    user_id: str
-
-
-@router.post("/login/wechat", response_model=LoginResponse)
-async def wechat_login(req: WechatLoginRequest):
-    url = (
-        f"https://api.weixin.qq.com/sns/jscode2session"
-        f"?appid={settings.wechat_appid}"
-        f"&secret={settings.wechat_secret}"
-        f"&js_code={req.code}"
-        f"&grant_type=authorization_code"
-    )
-    async with httpx.AsyncClient() as client:
-        resp = await client.get(url)
-        data = resp.json()
-
-    if "errcode" in data:
-        raise HTTPException(status_code=400, detail=f"Wechat login failed: {data.get('errmsg')}")
-
-    openid = data["openid"]
-    access_token = create_access_token(data={"sub": openid})
-    return LoginResponse(access_token=access_token, user_id=openid)

+ 0 - 648
backend-python/app/api/chat.py

@@ -1,648 +0,0 @@
-import base64
-import json
-import uuid
-from typing import Optional
-
-from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
-from fastapi.responses import StreamingResponse
-from pydantic import BaseModel, Field
-from sqlalchemy import text
-from sqlalchemy.ext.asyncio import create_async_engine
-
-from app.core.security import get_current_user, RateLimiter
-from app.rag.retriever import MixedRetriever, classify_intent
-from app.rag.reranker import Reranker
-from app.rag.prompt import build_prompt
-from app.core.llm_client import llm_client
-from app.core.config import get_settings
-
-settings = get_settings()
-router = APIRouter(prefix="/chat", tags=["对话"])
-
-retriever = MixedRetriever()
-reranker = Reranker()
-
-
-class ChatRequest(BaseModel):
-    message: str = Field(..., min_length=1, max_length=2000)
-    conversation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
-
-
-class ImageChatRequest(BaseModel):
-    """图片对话请求:base64 图片"""
-    model_config = {"populate_by_name": True}
-
-    image_base64: str = Field(..., min_length=1, alias="imageBase64", description="Base64 编码的图片")
-    mime_type: str = Field(default="image/jpeg", alias="mimeType", description="图片 MIME 类型")
-    message: str = Field(default="", max_length=2000, description="可选的附加文字问题")
-    conversation_id: str = Field(default_factory=lambda: str(uuid.uuid4()), alias="conversationId")
-
-
-class MultimodalChatRequest(BaseModel):
-    """统一多模态对话请求:支持文本 + 图片 + 视频"""
-    model_config = {"populate_by_name": True}
-
-    message: str = Field(default="", max_length=2000, description="文字问题(可选)")
-    # 媒体附件(图片和视频二选一或都不传,纯文本也可以)
-    media_type: str = Field(default="", alias="mediaType", description="媒体类型: image / video / 空=纯文本")
-    media_base64: str = Field(default="", alias="mediaBase64", description="Base64 编码的图片或视频")
-    media_mime: str = Field(default="", alias="mediaMime", description="媒体 MIME 类型,如 image/jpeg, video/mp4")
-    conversation_id: str = Field(default_factory=lambda: str(uuid.uuid4()), alias="conversationId")
-
-
-class ChatResponse(BaseModel):
-    answer: str
-    sources: list[dict]
-    conversation_id: str
-    intent: str
-
-
-class FeedbackRequest(BaseModel):
-    conversation_id: str
-    message_id: int
-    feedback: str
-
-
-# ============================================
-# DB helpers
-# ============================================
-
-async def _ensure_user(openid: str) -> int:
-    engine = create_async_engine(settings.database_url)
-    try:
-        async with engine.begin() as conn:
-            result = await conn.execute(
-                text("SELECT id FROM users WHERE openid = :openid"),
-                {"openid": openid},
-            )
-            row = result.fetchone()
-            if row:
-                return row[0]
-            result = await conn.execute(
-                text("INSERT INTO users (openid) VALUES (:openid) RETURNING id"),
-                {"openid": openid},
-            )
-            return result.fetchone()[0]
-    finally:
-        await engine.dispose()
-
-
-async def _save_message(conversation_id: str, role: str, content: str,
-                        intent: str = None, sources: list = None):
-    engine = create_async_engine(settings.database_url)
-    try:
-        async with engine.begin() as conn:
-            # 确保 conversation 存在(不要求 user_id 外键,因为 dev token 的 user 可能不在库中)
-            await conn.execute(
-                text("""
-                    INSERT INTO conversations (conversation_id, user_id, title)
-                    VALUES (:cid, 0, :title)
-                    ON CONFLICT (conversation_id) DO NOTHING
-                """),
-                {
-                    "cid": conversation_id,
-                    "title": content[:50] if role == "user" else "",
-                },
-            )
-            await conn.execute(
-                text("""
-                    INSERT INTO messages (conversation_id, role, content, intent, sources)
-                    VALUES (:cid, :role, :content, :intent, :sources)
-                """),
-                {
-                    "cid": conversation_id,
-                    "role": role,
-                    "content": content,
-                    "intent": intent,
-                    "sources": json.dumps(sources) if sources else None,
-                },
-            )
-    finally:
-        await engine.dispose()
-
-
-# ============================================
-# Chat endpoints
-# ============================================
-
-@router.post("/ask", response_model=ChatResponse)
-async def chat_ask(req: ChatRequest, user: dict = Depends(get_current_user)):
-    intent = classify_intent(req.message)
-    documents = await retriever.search(req.message, intent=intent, top_k=20)
-    documents = reranker.rerank(req.message, documents, top_k=5)
-
-    msgs = build_prompt(req.message, documents, intent=intent)
-    answer = await llm_client.chat(msgs)
-
-    sources = [
-        {"name": d.get("drug_name", d.get("source", "")),
-         "section": d.get("section", ""), "source": d.get("source", ""),
-         "score": d.get("score", 0)}
-        for d in documents
-    ]
-
-    # 保存到 DB
-    await _save_message(req.conversation_id, "user", req.message, intent)
-    await _save_message(req.conversation_id, "assistant", answer, intent, sources)
-
-    return ChatResponse(answer=answer, sources=sources,
-                        conversation_id=req.conversation_id, intent=intent)
-
-
-@router.post("/stream")
-async def chat_stream(req: ChatRequest, user: dict = Depends(get_current_user)):
-    async def stream_gen():
-        intent = classify_intent(req.message)
-        yield f"event: intent\ndata: {intent}\n\n"
-
-        yield "event: status\ndata: 正在检索...\n\n"
-        documents = await retriever.search(req.message, intent=intent, top_k=20)
-        documents = reranker.rerank(req.message, documents, top_k=5)
-
-        yield f"event: status\ndata: 已匹配 {len(documents)} 条,生成中...\n\n"
-        msgs = build_prompt(req.message, documents, intent=intent)
-
-        sources = [
-            {"name": d.get("drug_name", d.get("source", "")),
-             "section": d.get("section", ""), "source": d.get("source", ""),
-             "score": d.get("score", 0)}
-            for d in documents
-        ]
-
-        yield "event: content\n"
-        full_answer = []
-        async for token in llm_client.chat_stream(msgs):
-            full_answer.append(token)
-            yield f"data: {token}\n\n"
-        yield "data: [DONE]\n\n"
-
-        # 元数据追加
-        import json
-        yield f"event: meta\ndata: {json.dumps({'intent': intent, 'sources': sources, 'cid': req.conversation_id})}\n\n"
-
-        answer_text = "".join(full_answer)
-        await _save_message(req.conversation_id, "user", req.message, intent)
-        await _save_message(req.conversation_id, "assistant", answer_text, intent, sources)
-
-    return StreamingResponse(stream_gen(), media_type="text/event-stream")
-
-
-# ============================================
-# 图片对话 API(Qwen VL 分析 + OCR → RAG 检索 → 联网搜索)
-# ============================================
-
-@router.post("/ask-image", response_model=ChatResponse)
-async def chat_ask_image(req: ImageChatRequest, user: dict = Depends(get_current_user)):
-    """
-    图片对话 — 非流式:
-    1. Qwen VL 分析图片 + OCR 提取文字
-    2. 用提取文字做 RAG 检索
-    3. 结合检索结果 + 联网搜索生成回答
-    """
-    # Step 1: Qwen VL 分析图片 → 提取文字
-    ocr_text = await llm_client.analyze_image(
-        req.image_base64, req.mime_type,
-        prompt="请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等关键药学信息。简要输出即可。",
-    )
-
-    # Step 2: 拼接用户附加文字 + OCR 结果 → RAG 检索
-    query = req.message.strip() if req.message else ocr_text
-    if req.message:
-        query = f"{req.message}\n\n(图片OCR提取内容:{ocr_text})"
-
-    intent = classify_intent(query)
-    documents = await retriever.search(query, intent=intent, top_k=20)
-    documents = reranker.rerank(query, documents, top_k=5)
-
-    # Step 3: 构建 Prompt(含图片分析结果)+ 联网搜索
-    image_context = f"\n\n【图片分析结果】\n{ocr_text}\n"
-    msgs = build_prompt(query, documents, intent=intent)
-    # 在 system prompt 中追加图片分析上下文
-    msgs[0]["content"] += image_context
-    answer = await llm_client.chat(msgs, enable_search=True)
-
-    sources = [
-        {"name": d.get("drug_name", d.get("source", "")),
-         "section": d.get("section", ""), "source": d.get("source", ""),
-         "score": d.get("score", 0)}
-        for d in documents
-    ]
-
-    await _save_message(req.conversation_id, "user",
-                        f"[图片] {req.message}" if req.message else "[图片]",
-                        intent)
-    await _save_message(req.conversation_id, "assistant", answer, intent, sources)
-
-    return ChatResponse(answer=answer, sources=sources,
-                        conversation_id=req.conversation_id, intent=intent)
-
-
-@router.post("/stream-image")
-async def chat_stream_image(req: ImageChatRequest, user: dict = Depends(get_current_user)):
-    """图片对话 — SSE 流式"""
-
-    async def stream_gen():
-        intent = "drug_query"
-        yield f"event: intent\ndata: {intent}\n\n"
-
-        # Step 1: Qwen VL 流式分析图片 — 实时推给用户
-        yield "event: status\ndata: 🔍 正在分析图片...\n\n"
-        yield "event: content\ndata: 【📷 图片分析】\n\n"
-        ocr_parts = []
-        async for token in llm_client.analyze_image_stream(
-            req.image_base64, req.mime_type,
-            prompt="请分析这张图片,提取其中所有文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。",
-        ):
-            ocr_parts.append(token)
-            yield f"data: {token}\n\n"  # ← 实时流给用户
-        ocr_text = "".join(ocr_parts)
-        yield "data: \n\n"
-
-        # Step 2: 拼接查询 → RAG
-        query = req.message.strip() if req.message else ocr_text
-        if req.message:
-            query = f"{req.message}\n\n(图片OCR提取内容:{ocr_text})"
-
-        intent = classify_intent(query)
-        yield f"event: intent\ndata: {intent}\n\n"
-        yield f"event: status\ndata: 📚 检索药典知识库...\n\n"
-
-        documents = await retriever.search(query, intent=intent, top_k=20)
-        documents = reranker.rerank(query, documents, top_k=5)
-
-        yield f"event: status\ndata: 已匹配 {len(documents)} 条药典资料,生成回答中(已启用联网搜索)...\n\n"
-
-        # Step 3: 构建 Prompt + 联网搜索流式生成
-        image_context = f"\n\n【图片分析结果】\n{ocr_text}\n"
-        msgs = build_prompt(query, documents, intent=intent)
-        msgs[0]["content"] += image_context
-
-        sources = [
-            {"name": d.get("drug_name", d.get("source", "")),
-             "section": d.get("section", ""), "source": d.get("source", ""),
-             "score": d.get("score", 0)}
-            for d in documents
-        ]
-
-        yield "event: content\ndata: \n【📚 药典参考回答】\n\n"
-        full_answer = []
-        async for token in llm_client.chat_stream(msgs, enable_search=True):
-            full_answer.append(token)
-            yield f"data: {token}\n\n"
-        yield "data: [DONE]\n\n"
-
-        yield f"event: meta\ndata: {json.dumps({'intent': intent, 'sources': sources, 'cid': req.conversation_id, 'ocr_text': ocr_text[:200]})}\n\n"
-
-        answer_text = "".join(full_answer)
-        await _save_message(req.conversation_id, "user",
-                            f"[图片] {req.message}" if req.message else "[图片]",
-                            intent)
-        await _save_message(req.conversation_id, "assistant", answer_text, intent, sources)
-
-    return StreamingResponse(stream_gen(), media_type="text/event-stream")
-
-
-@router.post("/upload-image")
-async def chat_upload_image(
-    file: UploadFile = File(...),
-    message: str = Form(default=""),
-    conversation_id: str = Form(default=""),
-    user: dict = Depends(get_current_user),
-):
-    """
-    上传图片文件 → 转为 base64 → 走图片对话流程
-    支持格式:jpg, jpeg, png, webp, bmp
-    """
-    allowed = {"image/jpeg", "image/png", "image/webp", "image/bmp"}
-    if file.content_type and file.content_type not in allowed:
-        raise HTTPException(400, f"不支持的图片格式: {file.content_type},支持 jpg/png/webp/bmp")
-
-    contents = await file.read()
-    if len(contents) > 10 * 1024 * 1024:
-        raise HTTPException(400, "图片大小不能超过 10MB")
-
-    image_b64 = base64.b64encode(contents).decode("utf-8")
-    mime = file.content_type or "image/jpeg"
-    cid = conversation_id or str(uuid.uuid4())
-
-    req = ImageChatRequest(
-        image_base64=image_b64,
-        mime_type=mime,
-        message=message,
-        conversation_id=cid,
-    )
-    return await chat_ask_image(req, user)
-
-
-# ============================================================
-# 统一多模态对话 API(文本 + 图片 + 视频,一个接口全搞定)
-# ============================================================
-
-@router.post("/ask-multimodal", response_model=ChatResponse)
-async def chat_ask_multimodal(req: MultimodalChatRequest, user: dict = Depends(get_current_user)):
-    """
-    统一多模态对话 — 非流式:
-    支持纯文本 / 文本+图片 / 文本+视频 / 纯图片 / 纯视频
-    流程:媒体分析(OCR) → RAG 检索 → 联网搜索 → 回答
-    """
-    conversation_id = req.conversation_id or str(uuid.uuid4())
-    ocr_text = ""
-
-    # Step 1: 如果有媒体附件,先做视觉分析 + OCR
-    if req.media_base64 and req.media_type in ("image", "video"):
-        media_label = "视频" if req.media_type == "video" else "图片"
-        ocr_text = await llm_client.analyze_media(
-            req.media_base64,
-            req.media_type,
-            mime_type=req.media_mime or ("" if req.media_type != "video" else "video/mp4"),
-        )
-
-    # Step 2: 拼接查询文本
-    query = req.message.strip()
-    if query and ocr_text:
-        query = f"{query}\n\n({media_label}OCR提取内容:{ocr_text})"
-    elif ocr_text:
-        query = ocr_text
-    elif not query:
-        query = "请介绍一下自己"
-
-    # Step 3: RAG 检索
-    intent = classify_intent(query)
-    documents = await retriever.search(query, intent=intent, top_k=20)
-    documents = reranker.rerank(query, documents, top_k=5)
-
-    # Step 4: 构建 Prompt + 联网搜索
-    msgs = build_prompt(query, documents, intent=intent)
-    if ocr_text:
-        msgs[0]["content"] += f"\n\n【{media_label}分析结果】\n{ocr_text}\n"
-
-    answer = await llm_client.chat(msgs, enable_search=bool(ocr_text) or settings.enable_web_search)
-
-    sources = [
-        {"name": d.get("drug_name", d.get("source", "")),
-         "section": d.get("section", ""), "source": d.get("source", ""),
-         "score": d.get("score", 0)}
-        for d in documents
-    ]
-
-    user_msg = req.message or f"[{media_label}]" if ocr_text else req.message
-    await _save_message(conversation_id, "user", user_msg, intent)
-    await _save_message(conversation_id, "assistant", answer, intent, sources)
-
-    return ChatResponse(answer=answer, sources=sources,
-                        conversation_id=conversation_id, intent=intent)
-
-
-@router.post("/stream-multimodal")
-async def chat_stream_multimodal(req: MultimodalChatRequest, user: dict = Depends(get_current_user)):
-    """
-    统一多模态对话 — SSE 流式(全链路流式):
-    OCR 分析 → 实时推送给用户 → 立即 RAG 检索 → 流式生成回答
-    用户无需等待,每一步都在实时输出
-    """
-
-    async def stream_gen():
-        nonlocal req
-        conversation_id = req.conversation_id or str(uuid.uuid4())
-        media_label = ""
-        ocr_text = ""
-
-        has_media = req.media_base64 and req.media_type in ("image", "video")
-
-        if has_media:
-            media_label = "视频" if req.media_type == "video" else "图片"
-            # Step 1: 流式 OCR 分析 — 实时推送给用户
-            yield "event: status\ndata: 🔍 正在分析...\n\n"
-            yield f"event: content\ndata: 【📷 {media_label}分析】\n\n"
-
-            ocr_parts = []
-            async for token in llm_client.analyze_media_stream(
-                req.media_base64, req.media_type,
-                mime_type=req.media_mime or "",
-            ):
-                ocr_parts.append(token)
-                yield f"data: {token}\n\n"  # ← OCR token 实时流给用户
-
-            ocr_text = "".join(ocr_parts)
-            yield "data: \n\n"  # 分隔
-        else:
-            yield "event: intent\ndata: drug_query\n\n"
-
-        # Step 2: 拼接查询 → RAG 检索(此时 OCR 已全部拿到)
-        query = req.message.strip()
-        if query and ocr_text:
-            query = f"{query}\n\n({media_label}OCR提取内容:{ocr_text})"
-        elif ocr_text:
-            query = ocr_text
-        elif not query:
-            query = "请介绍一下自己"
-
-        intent = classify_intent(query)
-        yield f"event: intent\ndata: {intent}\n\n"
-        yield f"event: status\ndata: 📚 检索药典知识库...\n\n"
-
-        documents = await retriever.search(query, intent=intent, top_k=20)
-        documents = reranker.rerank(query, documents, top_k=5)
-
-        search_hint = "(已启用联网搜索)" if (ocr_text or settings.enable_web_search) else ""
-        yield f"event: status\ndata: 已匹配 {len(documents)} 条,生成回答中{search_hint}...\n\n"
-
-        # Step 3: LLM 流式生成
-        msgs = build_prompt(query, documents, intent=intent)
-        if ocr_text:
-            msgs[0]["content"] += f"\n\n【{media_label}分析结果】\n{ocr_text}\n"
-
-        sources = [
-            {"name": d.get("drug_name", d.get("source", "")),
-             "section": d.get("section", ""), "source": d.get("source", ""),
-             "score": d.get("score", 0)}
-            for d in documents
-        ]
-
-        yield "event: content\ndata: \n【📚 药典参考回答】\n\n"
-        full_answer = []
-        async for token in llm_client.chat_stream(msgs, enable_search=bool(ocr_text) or settings.enable_web_search):
-            full_answer.append(token)
-            yield f"data: {token}\n\n"
-        yield "data: [DONE]\n\n"
-
-        yield f"event: meta\ndata: {json.dumps({'intent': intent, 'sources': sources, 'cid': conversation_id, 'ocr_text': ocr_text[:200] if ocr_text else ''})}\n\n"
-
-        answer_text = "".join(full_answer)
-        user_msg = req.message or f"[{media_label}]" if ocr_text else req.message
-        await _save_message(conversation_id, "user", user_msg, intent)
-        await _save_message(conversation_id, "assistant", answer_text, intent, sources)
-
-    return StreamingResponse(stream_gen(), media_type="text/event-stream")
-
-
-@router.post("/upload-media")
-async def chat_upload_media(
-    file: UploadFile = File(...),
-    message: str = Form(default=""),
-    conversation_id: str = Form(default=""),
-    user: dict = Depends(get_current_user),
-):
-    """
-    上传媒体文件(图片/视频)→ 自动识别类型 → 走多模态对话流程
-    支持:jpg, jpeg, png, webp, bmp, mp4, mov, avi, webm
-    """
-    mime = file.content_type or ""
-    media_type = ""
-    if mime.startswith("image/"):
-        media_type = "image"
-        max_size = 10 * 1024 * 1024  # 10MB
-    elif mime.startswith("video/"):
-        media_type = "video"
-        max_size = 50 * 1024 * 1024  # 50MB
-    else:
-        raise HTTPException(400, f"不支持的媒体格式: {mime},支持 jpg/png/webp/bmp/mp4/mov/avi/webm")
-
-    contents = await file.read()
-    if len(contents) > max_size:
-        raise HTTPException(400, f"文件大小不能超过 {max_size // 1024 // 1024}MB")
-
-    media_b64 = base64.b64encode(contents).decode("utf-8")
-    cid = conversation_id or str(uuid.uuid4())
-
-    req = MultimodalChatRequest(
-        message=message,
-        media_type=media_type,
-        media_base64=media_b64,
-        media_mime=mime,
-        conversation_id=cid,
-    )
-    return await chat_ask_multimodal(req, user)
-
-
-# ============================================
-# 对话历史 API
-# ============================================
-
-@router.get("/history")
-async def get_history(
-    page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=50),
-    user: dict = Depends(get_current_user),
-):
-    engine = create_async_engine(settings.database_url)
-    try:
-        async with engine.connect() as conn:
-            offset = (page - 1) * page_size
-            result = await conn.execute(
-                text("""
-                    SELECT c.conversation_id, c.title, c.created_at,
-                           COUNT(m.id) as msg_count
-                    FROM conversations c
-                    LEFT JOIN messages m ON m.conversation_id = c.conversation_id
-                    GROUP BY c.id
-                    ORDER BY c.created_at DESC
-                    LIMIT :limit OFFSET :offset
-                """),
-                {"limit": page_size, "offset": offset},
-            )
-            items = []
-            for row in result.fetchall():
-                items.append({
-                    "conversation_id": row[0],
-                    "title": row[1] or "新的对话",
-                    "created_at": row[2].isoformat() if row[2] else "",
-                    "message_count": row[3],
-                })
-            return {"items": items, "page": page, "page_size": page_size}
-    finally:
-        await engine.dispose()
-
-
-@router.get("/history/{conversation_id}")
-async def get_conversation_detail(
-    conversation_id: str,
-    user: dict = Depends(get_current_user),
-):
-    engine = create_async_engine(settings.database_url)
-    try:
-        async with engine.connect() as conn:
-            result = await conn.execute(
-                text("""
-                    SELECT id, role, content, intent, sources, created_at
-                    FROM messages
-                    WHERE conversation_id = :cid
-                    ORDER BY created_at ASC
-                """),
-                {"cid": conversation_id},
-            )
-            messages = []
-            for row in result.fetchall():
-                messages.append({
-                    "id": row[0],
-                    "role": row[1],
-                    "content": row[2],
-                    "intent": row[3],
-                    "sources": row[4] if row[4] else [],
-                    "created_at": row[5].isoformat() if row[5] else "",
-                })
-            return {"conversation_id": conversation_id, "messages": messages}
-    finally:
-        await engine.dispose()
-
-
-@router.post("/feedback")
-async def submit_feedback(req: FeedbackRequest, user: dict = Depends(get_current_user)):
-    engine = create_async_engine(settings.database_url)
-    try:
-        async with engine.begin() as conn:
-            await conn.execute(
-                text("UPDATE messages SET feedback = :fb WHERE id = :mid"),
-                {"fb": req.feedback, "mid": req.message_id},
-            )
-        return {"ok": True, "message_id": req.message_id, "feedback": req.feedback}
-    finally:
-        await engine.dispose()
-
-
-# ============================================
-# 管理员:查看全部对话
-# ============================================
-
-@router.get("/admin/conversations")
-async def admin_list_conversations(
-    page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=100),
-    keyword: Optional[str] = Query(None, description="搜索用户提问关键词"),
-    user: dict = Depends(get_current_user),
-):
-    engine = create_async_engine(settings.database_url)
-    try:
-        async with engine.connect() as conn:
-            offset = (page - 1) * page_size
-            where = ""
-            params = {"limit": page_size, "offset": offset}
-            if keyword:
-                where = "WHERE m.content LIKE :kw"
-                params["kw"] = f"%{keyword}%"
-
-            result = await conn.execute(
-                text(f"""
-                    SELECT DISTINCT ON (c.conversation_id)
-                           c.conversation_id, c.title, c.created_at,
-                           m.content as last_msg, m.role
-                    FROM conversations c
-                    JOIN messages m ON m.conversation_id = c.conversation_id
-                    {where}
-                    ORDER BY c.conversation_id, m.created_at DESC
-                    LIMIT :limit OFFSET :offset
-                """),
-                params,
-            )
-            items = []
-            for row in result.fetchall():
-                items.append({
-                    "conversation_id": row[0],
-                    "title": row[1] or "新的对话",
-                    "created_at": row[2].isoformat() if row[2] else "",
-                    "last_message": (row[3] or "")[:200],
-                    "last_role": row[4],
-                })
-            return {"items": items, "page": page, "page_size": page_size}
-    finally:
-        await engine.dispose()

+ 0 - 127
backend-python/app/api/drug.py

@@ -1,127 +0,0 @@
-from typing import Optional
-
-from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import text
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.core.security import get_current_user
-from app.models.drug import get_db
-
-router = APIRouter(prefix="/drug", tags=["药品"])
-
-
-@router.get("/search")
-async def search_drug(
-    keyword: Optional[str] = Query(None),
-    category: Optional[str] = Query(None),
-    page: int = Query(1, ge=1),
-    page_size: int = Query(20, ge=1, le=100),
-    user: dict = Depends(get_current_user),
-):
-    db = get_db()
-    offset = (page - 1) * page_size
-
-    conditions = ["is_active = TRUE"]
-    params = {}
-
-    if keyword:
-        conditions.append("(name ILIKE :kw OR pinyin ILIKE :kw OR name_en ILIKE :kw)")
-        params["kw"] = f"%{keyword}%"
-    if category:
-        conditions.append("category = :cat")
-        params["cat"] = category
-
-    where = "WHERE " + " AND ".join(conditions)
-
-    count_query = f"SELECT COUNT(*) FROM drugs {where}"
-    data_query = f"""
-        SELECT drug_id, name, name_en, pinyin, category, subcategory,
-               source_version, source_volume, source_page, is_active
-        FROM drugs {where}
-        ORDER BY name
-        LIMIT :limit OFFSET :offset
-    """
-
-    params["limit"] = page_size
-    params["offset"] = offset
-
-    async with db() as session:
-        total = (await session.execute(text(count_query), params)).scalar()
-        rows = (await session.execute(text(data_query), params)).fetchall()
-
-    items = []
-    for row in rows:
-        items.append({
-            "drug_id": row.drug_id,
-            "name": row.name,
-            "name_en": row.name_en or "",
-            "pinyin": row.pinyin or "",
-            "category": row.category or "",
-            "subcategory": row.subcategory or "",
-            "source_version": row.source_version or "",
-            "source_volume": row.source_volume or "",
-            "source_page": row.source_page or "",
-            "is_active": row.is_active,
-        })
-
-    total_pages = max(1, (total + page_size - 1) // page_size)
-
-    return {
-        "items": items,
-        "total": total,
-        "page": page,
-        "page_size": page_size,
-        "total_pages": total_pages,
-    }
-
-
-@router.get("/{drug_id}")
-async def get_drug_detail(
-    drug_id: str,
-    user: dict = Depends(get_current_user),
-):
-    db = get_db()
-    async with db() as session:
-        row = (await session.execute(
-            text("SELECT * FROM drugs WHERE drug_id = :drug_id"),
-            {"drug_id": drug_id}
-        )).fetchone()
-
-    if not row:
-        raise HTTPException(status_code=404, detail="Drug not found")
-
-    return {
-        "data": {
-            "drug_id": row.drug_id,
-            "name": row.name,
-            "name_en": row.name_en or "",
-            "pinyin": row.pinyin or "",
-            "category": row.category or "",
-            "subcategory": row.subcategory or "",
-            "sections": row.sections or {},
-            "source_version": row.source_version or "",
-            "source_volume": row.source_volume or "",
-            "source_page": row.source_page or "",
-            "is_active": row.is_active,
-        }
-    }
-
-
-@router.get("/category/tree")
-async def get_category_tree(user: dict = Depends(get_current_user)):
-    db = get_db()
-    async with db() as session:
-        rows = (await session.execute(
-            text("SELECT DISTINCT category, subcategory FROM drugs WHERE category IS NOT NULL ORDER BY category, subcategory")
-        )).fetchall()
-
-    tree = {}
-    for row in rows:
-        cat = row.category
-        sub = row.subcategory or ""
-        if cat not in tree:
-            tree[cat] = []
-        if sub and sub not in tree[cat]:
-            tree[cat].append(sub)
-
-    return {"tree": [{"name": k, "children": v} for k, v in tree.items()]}

+ 0 - 0
backend-python/app/api/exam/__init__.py


+ 0 - 54
backend-python/app/api/exam/chapter.py

@@ -1,54 +0,0 @@
-from fastapi import APIRouter, Depends
-
-from app.core.security import get_current_user
-
-router = APIRouter(prefix="/exam/chapter", tags=["学习-章节"])
-
-
-@router.get("/tree")
-async def get_chapter_tree(user: dict = Depends(get_current_user)):
-    return {
-        "subjects": [
-            {
-                "id": "yao1",
-                "name": "药学专业知识一",
-                "chapters": [
-                    {"id": "ch01", "name": "药品与药品质量标准", "knowledge_count": 0},
-                    {"id": "ch02", "name": "药物的结构与作用", "knowledge_count": 0},
-                ],
-            },
-            {
-                "id": "yao2",
-                "name": "药学专业知识二",
-                "chapters": [
-                    {"id": "ch01", "name": "精神与中枢神经系统疾病用药", "knowledge_count": 0},
-                ],
-            },
-            {
-                "id": "fagui",
-                "name": "药事管理与法规",
-                "chapters": [
-                    {"id": "ch01", "name": "执业药师与健康中国战略", "knowledge_count": 0},
-                ],
-            },
-            {
-                "id": "zonghe",
-                "name": "药学综合知识与技能",
-                "chapters": [
-                    {"id": "ch01", "name": "执业药师与药学服务", "knowledge_count": 0},
-                ],
-            },
-        ]
-    }
-
-
-@router.get("/{chapter_id}")
-async def get_chapter_knowledge(
-    chapter_id: str,
-    user: dict = Depends(get_current_user),
-):
-    return {
-        "chapter_id": chapter_id,
-        "knowledge_points": [],
-        "message": "待数据入库后可用",
-    }

+ 0 - 32
backend-python/app/api/exam/exam_qa.py

@@ -1,32 +0,0 @@
-from fastapi import APIRouter, Depends
-from fastapi.responses import StreamingResponse
-from pydantic import BaseModel, Field
-
-from app.core.security import get_current_user
-
-router = APIRouter(prefix="/exam/qa", tags=["学习-答疑"])
-
-
-class ExamQARequest(BaseModel):
-    question: str = Field(..., min_length=1, max_length=2000)
-    subject: str = Field(default="", description="科目过滤")
-
-
-@router.post("/ask")
-async def exam_ask(
-    req: ExamQARequest,
-    user: dict = Depends(get_current_user),
-):
-    return {"answer": "待考试知识库构建后可用", "sources": []}
-
-
-@router.post("/stream")
-async def exam_stream(
-    req: ExamQARequest,
-    user: dict = Depends(get_current_user),
-):
-    async def stream_gen():
-        yield "data: 待考试知识库构建后可用\n\n"
-        yield "data: [DONE]\n\n"
-
-    return StreamingResponse(stream_gen(), media_type="text/event-stream")

+ 0 - 38
backend-python/app/api/exam/practice.py

@@ -1,38 +0,0 @@
-from typing import Optional
-
-from fastapi import APIRouter, Depends, Query
-from pydantic import BaseModel
-
-from app.core.security import get_current_user
-
-router = APIRouter(prefix="/exam/practice", tags=["学习-刷题"])
-
-
-class AnswerRequest(BaseModel):
-    question_id: str
-    answer: str
-
-
-@router.get("/questions")
-async def get_questions(
-    subject: Optional[str] = Query(None, description="科目"),
-    chapter_id: Optional[str] = Query(None, description="章节"),
-    question_type: Optional[str] = Query(None, description="A/B/X 题型"),
-    mode: str = Query("sequential", description="sequential/random/wrong"),
-    count: int = Query(10, ge=1, le=50),
-    user: dict = Depends(get_current_user),
-):
-    return {"questions": [], "message": "待题库生成后可用"}
-
-
-@router.post("/submit")
-async def submit_answer(
-    req: AnswerRequest,
-    user: dict = Depends(get_current_user),
-):
-    return {"correct": True, "correct_answer": "", "explanation": ""}
-
-
-@router.get("/wrong-questions")
-async def get_wrong_questions(user: dict = Depends(get_current_user)):
-    return {"items": []}

+ 0 - 22
backend-python/app/api/exam/progress.py

@@ -1,22 +0,0 @@
-from fastapi import APIRouter, Depends
-
-from app.core.security import get_current_user
-
-router = APIRouter(prefix="/exam/progress", tags=["学习-进度"])
-
-
-@router.get("/summary")
-async def get_progress_summary(user: dict = Depends(get_current_user)):
-    return {
-        "total_questions_answered": 0,
-        "correct_rate": 0.0,
-        "study_streak_days": 0,
-        "total_study_minutes": 0,
-        "weak_subjects": [],
-        "recommendations": [],
-    }
-
-
-@router.get("/chapters")
-async def get_chapter_progress(user: dict = Depends(get_current_user)):
-    return {"chapters": []}

+ 0 - 0
backend-python/app/core/__init__.py


+ 0 - 84
backend-python/app/core/config.py

@@ -1,84 +0,0 @@
-from pydantic_settings import BaseSettings
-from functools import lru_cache
-from pathlib import Path
-import os
-
-
-def _find_env_file():
-    candidates = [Path.cwd() / ".env", Path(__file__).resolve().parent.parent.parent.parent / ".env"]
-    for p in candidates:
-        if p.exists():
-            return str(p)
-    return ".env"
-
-
-class Settings(BaseSettings):
-    app_name: str = "PharmacopoeiaAI"
-    app_env: str = "development"
-    app_debug: bool = True
-    secret_key: str = "change-me"
-    api_prefix: str = "/api/v1"
-
-    postgres_host: str = "localhost"
-    postgres_port: int = 5432
-    postgres_db: str = "pharmacopoeia"
-    postgres_user: str = "postgres"
-    postgres_password: str = "postgres"
-
-    redis_host: str = "localhost"
-    redis_port: int = 6379
-    redis_password: str = ""
-    redis_db: int = 0
-
-    milvus_host: str = "localhost"
-    milvus_port: int = 19530
-    milvus_collection: str = "drug_entries"
-
-    llm_provider: str = "qwen"
-    qwen_api_key: str = ""
-    qwen_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-    qwen_model: str = "qwen-max"
-    qwen_max_tokens: int = 4096
-    qwen_temperature: float = 0.1
-
-    qwen_vl_model: str = "qwen3.6-flash"        # 视觉模型(图片分析+OCR)
-    enable_web_search: bool = True             # 是否启用 Qwen 联网搜索
-
-    qwen_local_base_url: str = "http://localhost:8000/v1"
-    qwen_local_model: str = "Qwen3-35B-A3B"
-
-    embedding_model: str = "BAAI/bge-m3"
-    embedding_dim: int = 1024
-    embedding_device: str = "cpu"
-
-    reranker_model: str = "BAAI/bge-reranker-v2-m3"
-
-    wechat_appid: str = ""
-    wechat_secret: str = ""
-
-    rate_limit_per_minute: int = 60
-    rate_limit_per_hour: int = 1000
-
-    log_level: str = "INFO"
-    log_file: str = "logs/app.log"
-
-    @property
-    def database_url(self) -> str:
-        return (
-            f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
-            f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
-        )
-
-    @property
-    def database_url_sync(self) -> str:
-        return (
-            f"postgresql://{self.postgres_user}:{self.postgres_password}"
-            f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
-        )
-
-    model_config = {"env_file": _find_env_file(), "case_sensitive": False, "extra": "ignore"}
-
-
-@lru_cache
-def get_settings() -> Settings:
-    return Settings()

+ 0 - 311
backend-python/app/core/llm_client.py

@@ -1,311 +0,0 @@
-import base64
-from typing import AsyncIterator, Optional
-
-from openai import AsyncOpenAI
-
-from app.core.config import get_settings
-
-
-class LLMClient:
-    def __init__(self):
-        self._client = None
-
-    def _ensure_client(self):
-        if self._client is not None:
-            return
-        settings = get_settings()
-        is_local = settings.qwen_base_url.startswith("http://localhost")
-        api_key = "local" if is_local else settings.qwen_api_key
-        if not api_key and not is_local:
-            raise ValueError("QWEN_API_KEY 未设置,请在 .env 文件中填入 API Key")
-        self._client = AsyncOpenAI(
-            api_key=api_key,
-            base_url=settings.qwen_base_url,
-        )
-        self.model = settings.qwen_model
-        self.vl_model = settings.qwen_vl_model
-        self.enable_web_search = settings.enable_web_search
-
-    # ============================================================
-    # 纯文本对话
-    # ============================================================
-
-    async def chat(
-        self,
-        messages: list[dict],
-        temperature: Optional[float] = None,
-        max_tokens: Optional[int] = None,
-        enable_search: bool = False,
-    ) -> str:
-        self._ensure_client()
-        settings = get_settings()
-        extra = {}
-        if enable_search:
-            extra["enable_search"] = True
-        response = await self._client.chat.completions.create(
-            model=self.model,
-            messages=messages,
-            temperature=temperature or settings.qwen_temperature,
-            max_tokens=max_tokens or settings.qwen_max_tokens,
-            extra_body=extra if extra else None,
-        )
-        return response.choices[0].message.content or ""
-
-    async def chat_stream(
-        self,
-        messages: list[dict],
-        temperature: Optional[float] = None,
-        max_tokens: Optional[int] = None,
-        enable_search: bool = False,
-    ) -> AsyncIterator[str]:
-        self._ensure_client()
-        settings = get_settings()
-        extra = {}
-        if enable_search:
-            extra["enable_search"] = True
-        stream = await self._client.chat.completions.create(
-            model=self.model,
-            messages=messages,
-            temperature=temperature or settings.qwen_temperature,
-            max_tokens=max_tokens or settings.qwen_max_tokens,
-            stream=True,
-            extra_body=extra if extra else None,
-        )
-        async for chunk in stream:
-            if chunk.choices and chunk.choices[0].delta.content:
-                yield chunk.choices[0].delta.content
-
-    # ============================================================
-    # 图片分析 + OCR(Qwen VL 视觉模型)
-    # ============================================================
-
-    async def analyze_image(
-        self,
-        image_base64: str,
-        mime_type: str = "image/jpeg",
-        prompt: str = "",
-    ) -> str:
-        """
-        调用 Qwen VL 模型分析图片:
-        - 描述图片内容(药品包装、说明书、处方等)
-        - 提取图中文字(OCR)
-        - 返回可用于 RAG 检索的文本
-        """
-        self._ensure_client()
-
-        if not prompt:
-            prompt = """请仔细分析这张图片,完成以下任务:
-1. 描述图片内容(药品包装、说明书、处方单、症状照片等)
-2. 提取图片中所有可见的文字(OCR),特别是药品名称、成分、用法用量、批号、有效期等关键信息
-3. 如果图片是药品包装/说明书,提取:药品通用名、规格、生产企业、批准文号
-4. 如果图片是处方,提取:患者信息、药品名称、用法用量、开具日期
-
-请按以下格式输出:
-
-【图片描述】
-图片内容的简要描述。
-
-【OCR 提取文字】
-逐条列出提取到的文字内容。
-"""
-
-        response = await self._client.chat.completions.create(
-            model=self.vl_model,
-            messages=[
-                {
-                    "role": "user",
-                    "content": [
-                        {
-                            "type": "image_url",
-                            "image_url": {
-                                "url": f"data:{mime_type};base64,{image_base64}"
-                            },
-                        },
-                        {"type": "text", "text": prompt},
-                    ],
-                }
-            ],
-            max_tokens=2048,
-        )
-        return response.choices[0].message.content or ""
-
-    async def analyze_image_stream(
-        self,
-        image_base64: str,
-        mime_type: str = "image/jpeg",
-        prompt: str = "",
-    ) -> AsyncIterator[str]:
-        """流式版本:Qwen VL 分析图片"""
-        self._ensure_client()
-
-        if not prompt:
-            prompt = """请仔细分析这张图片,完成以下任务:
-1. 描述图片内容(药品包装、说明书、处方单、症状照片等)
-2. 提取图片中所有可见的文字(OCR),特别是药品名称、成分、用法用量等关键信息
-3. 如果图片是药品包装/说明书,提取:药品通用名、规格、生产企业、批准文号
-请简要输出分析结果。"""
-
-        stream = await self._client.chat.completions.create(
-            model=self.vl_model,
-            messages=[
-                {
-                    "role": "user",
-                    "content": [
-                        {
-                            "type": "image_url",
-                            "image_url": {
-                                "url": f"data:{mime_type};base64,{image_base64}"
-                            },
-                        },
-                        {"type": "text", "text": prompt},
-                    ],
-                }
-            ],
-            max_tokens=2048,
-            stream=True,
-        )
-        async for chunk in stream:
-            if chunk.choices and chunk.choices[0].delta.content:
-                yield chunk.choices[0].delta.content
-
-    # ============================================================
-    # 视频分析 + OCR(Qwen VL 模型支持视频帧提取分析)
-    # ============================================================
-
-    async def analyze_video(
-        self,
-        video_base64: str,
-        mime_type: str = "video/mp4",
-        prompt: str = "",
-    ) -> str:
-        """
-        调用 Qwen VL 模型分析视频:
-        - 提取关键帧并描述视频内容
-        - OCR 提取帧中所有可见文字
-        - 返回可用于 RAG 检索的文本
-        """
-        self._ensure_client()
-
-        if not prompt:
-            prompt = """请仔细分析这段视频,完成以下任务:
-1. 描述视频内容(药品展示、用药指导、症状表现等)
-2. 提取视频中所有可见的文字(OCR),特别是药品名称、成分、用法用量、批号等关键信息
-3. 如果视频中有药品包装/说明书,提取全部文字信息
-4. 总结视频传达的关键药学信息
-
-请按以下格式输出:
-
-【视频描述】
-视频内容的简要描述。
-
-【OCR 提取文字】
-逐条列出从视频帧中提取到的文字内容。
-
-【关键信息总结】
-与药学相关的关键信息摘要。"""
-
-        response = await self._client.chat.completions.create(
-            model=self.vl_model,
-            messages=[
-                {
-                    "role": "user",
-                    "content": [
-                        {
-                            "type": "video_url",
-                            "video_url": {
-                                "url": f"data:{mime_type};base64,{video_base64}"
-                            },
-                        },
-                        {"type": "text", "text": prompt},
-                    ],
-                }
-            ],
-            max_tokens=2048,
-        )
-        return response.choices[0].message.content or ""
-
-    async def analyze_video_stream(
-        self,
-        video_base64: str,
-        mime_type: str = "video/mp4",
-        prompt: str = "",
-    ) -> AsyncIterator[str]:
-        """流式版本:Qwen VL 分析视频"""
-        self._ensure_client()
-
-        if not prompt:
-            prompt = """请分析这段视频,提取关键帧中的文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。"""
-
-        stream = await self._client.chat.completions.create(
-            model=self.vl_model,
-            messages=[
-                {
-                    "role": "user",
-                    "content": [
-                        {
-                            "type": "video_url",
-                            "video_url": {
-                                "url": f"data:{mime_type};base64,{video_base64}"
-                            },
-                        },
-                        {"type": "text", "text": prompt},
-                    ],
-                }
-            ],
-            max_tokens=2048,
-            stream=True,
-        )
-        async for chunk in stream:
-            if chunk.choices and chunk.choices[0].delta.content:
-                yield chunk.choices[0].delta.content
-
-    # ============================================================
-    # 统一多模态分析(自动判断 image / video)
-    # ============================================================
-
-    async def analyze_media(
-        self,
-        media_base64: str,
-        media_type: str,  # "image" or "video"
-        mime_type: str = "",
-        prompt: str = "",
-    ) -> str:
-        """统一入口:根据 media_type 自动路由到图片或视频分析"""
-        if media_type == "video":
-            return await self.analyze_video(
-                media_base64,
-                mime_type=mime_type or "video/mp4",
-                prompt=prompt,
-            )
-        else:
-            return await self.analyze_image(
-                media_base64,
-                mime_type=mime_type or "image/jpeg",
-                prompt=prompt,
-            )
-
-    async def analyze_media_stream(
-        self,
-        media_base64: str,
-        media_type: str,
-        mime_type: str = "",
-        prompt: str = "",
-    ) -> AsyncIterator[str]:
-        """流式统一入口"""
-        if media_type == "video":
-            async for token in self.analyze_video_stream(
-                media_base64,
-                mime_type=mime_type or "video/mp4",
-                prompt=prompt,
-            ):
-                yield token
-        else:
-            async for token in self.analyze_image_stream(
-                media_base64,
-                mime_type=mime_type or "image/jpeg",
-                prompt=prompt,
-            ):
-                yield token
-
-
-llm_client = LLMClient()

+ 0 - 76
backend-python/app/core/security.py

@@ -1,76 +0,0 @@
-from datetime import datetime, timedelta, timezone
-from typing import Any, Optional
-
-from jose import jwt, JWTError
-from passlib.context import CryptContext
-from fastapi import Depends, HTTPException, status
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from redis.asyncio import Redis
-
-from app.core.config import get_settings
-
-settings = get_settings()
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-security_scheme = HTTPBearer()
-
-
-def verify_password(plain_password: str, hashed_password: str) -> bool:
-    return pwd_context.verify(plain_password, hashed_password)
-
-
-def hash_password(password: str) -> str:
-    return pwd_context.hash(password)
-
-
-def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
-    to_encode = data.copy()
-    expire = datetime.now(timezone.utc) + (
-        expires_delta or timedelta(hours=24)
-    )
-    to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
-    return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
-
-
-def decode_access_token(token: str) -> Optional[dict]:
-    try:
-        return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
-    except (JWTError, Exception):
-        return None
-
-
-async def get_current_user(
-    credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
-) -> dict:
-    payload = decode_access_token(credentials.credentials)
-    if payload is None:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid or expired token",
-        )
-    return payload
-
-
-class RateLimiter:
-    def __init__(self, redis_client: Redis):
-        self.redis = redis_client
-
-    async def is_rate_limited(self, user_id: str) -> bool:
-        minute_key = f"rate_limit:minute:{user_id}"
-        hour_key = f"rate_limit:hour:{user_id}"
-
-        current_minute = await self.redis.get(minute_key)
-        current_hour = await self.redis.get(hour_key)
-
-        if current_minute and int(current_minute) >= settings.rate_limit_per_minute:
-            return True
-        if current_hour and int(current_hour) >= settings.rate_limit_per_hour:
-            return True
-
-        async with self.redis.pipeline() as pipe:
-            pipe.incr(minute_key)
-            pipe.expire(minute_key, 60)
-            pipe.incr(hour_key)
-            pipe.expire(hour_key, 3600)
-            await pipe.execute()
-
-        return False

+ 0 - 79
backend-python/app/main.py

@@ -1,79 +0,0 @@
-from contextlib import asynccontextmanager
-import logging
-from pathlib import Path
-
-from fastapi import FastAPI, Request
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import JSONResponse
-from fastapi.staticfiles import StaticFiles
-
-from app.core.config import get_settings
-from app.api import chat, drug, auth, admin
-from app.api import admin_knowledge
-from app.api.exam import chapter, practice, exam_qa, progress
-
-settings = get_settings()
-
-logging.basicConfig(
-    level=settings.log_level,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-    handlers=[
-        logging.FileHandler(settings.log_file),
-        logging.StreamHandler(),
-    ],
-)
-logger = logging.getLogger(__name__)
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
-    logger.info(f"Starting {settings.app_name} in {settings.app_env} mode")
-    yield
-    logger.info(f"Shutting down {settings.app_name}")
-
-
-app = FastAPI(
-    title=settings.app_name,
-    version="1.0.0",
-    lifespan=lifespan,
-)
-
-app.add_middleware(
-    CORSMiddleware,
-    allow_origins=["*"],
-    allow_credentials=True,
-    allow_methods=["*"],
-    allow_headers=["*"],
-)
-
-app.include_router(chat.router, prefix=settings.api_prefix)
-app.include_router(drug.router, prefix=settings.api_prefix)
-app.include_router(auth.router, prefix=settings.api_prefix)
-app.include_router(admin.router, prefix=settings.api_prefix)
-app.include_router(admin_knowledge.router, prefix=settings.api_prefix)
-app.include_router(chapter.router, prefix=settings.api_prefix)
-app.include_router(practice.router, prefix=settings.api_prefix)
-app.include_router(exam_qa.router, prefix=settings.api_prefix)
-app.include_router(progress.router, prefix=settings.api_prefix)
-
-# 静态文件
-static_dir = Path(__file__).resolve().parent.parent.parent / "static"
-static_dir.mkdir(parents=True, exist_ok=True)
-app.mount("/static", StaticFiles(directory=str(static_dir), html=True), name="static")
-
-@app.get("/")
-async def root():
-    from fastapi.responses import FileResponse
-    return FileResponse(static_dir / "index.html")
-
-@app.get("/health")
-async def health_check():
-    return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
-
-@app.exception_handler(Exception)
-async def global_exception_handler(request: Request, exc: Exception):
-    logger.error(f"Unhandled exception: {exc}", exc_info=True)
-    return JSONResponse(
-        status_code=500,
-        content={"detail": "Internal server error"},
-    )

+ 0 - 0
backend-python/app/models/__init__.py


+ 0 - 5
backend-python/app/models/base.py

@@ -1,5 +0,0 @@
-from sqlalchemy.orm import DeclarativeBase
-
-
-class Base(DeclarativeBase):
-    pass

+ 0 - 40
backend-python/app/models/conversation.py

@@ -1,40 +0,0 @@
-from typing import Optional
-from datetime import datetime, timezone
-
-from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, JSON, func
-from sqlalchemy.orm import Mapped, mapped_column, relationship
-
-from app.models.user import Base
-
-
-class Conversation(Base):
-    __tablename__ = "conversations"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    conversation_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
-    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
-    title: Mapped[Optional[str]] = mapped_column(String(256))
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )
-
-    messages: Mapped[list["Message"]] = relationship(back_populates="conversation")
-
-
-class Message(Base):
-    __tablename__ = "messages"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    conversation_id: Mapped[str] = mapped_column(
-        ForeignKey("conversations.conversation_id"), nullable=False, index=True
-    )
-    role: Mapped[str] = mapped_column(String(32), nullable=False)  # user / assistant
-    content: Mapped[str] = mapped_column(Text, nullable=False)
-    intent: Mapped[Optional[str]] = mapped_column(String(32))
-    sources: Mapped[Optional[dict]] = mapped_column(JSON)
-    feedback: Mapped[Optional[str]] = mapped_column(String(32))  # like / dislike / null
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )
-
-    conversation: Mapped["Conversation"] = relationship(back_populates="messages")

+ 0 - 63
backend-python/app/models/drug.py

@@ -1,63 +0,0 @@
-from typing import Optional
-from datetime import datetime, timezone
-
-from sqlalchemy import Column, String, Text, Integer, Float, DateTime, JSON, func
-from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
-from sqlalchemy.orm import Mapped, mapped_column
-
-from app.models.user import Base
-
-
-engine = create_async_engine(
-    "postgresql+asyncpg://postgres:pharma2025@localhost:5432/pharmacopoeia",
-    echo=False,
-    pool_size=5,
-    max_overflow=10,
-)
-
-async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
-
-
-def get_db():
-    return async_session_factory
-
-
-class Drug(Base):
-    __tablename__ = "drugs"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    drug_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
-    name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
-    name_en: Mapped[Optional[str]] = mapped_column(String(256))
-    pinyin: Mapped[Optional[str]] = mapped_column(String(256))
-    category: Mapped[Optional[str]] = mapped_column(String(64), index=True)
-    subcategory: Mapped[Optional[str]] = mapped_column(String(128))
-    approval_number: Mapped[Optional[str]] = mapped_column(String(64))
-
-    sections: Mapped[Optional[dict]] = mapped_column(JSON)
-    source_version: Mapped[Optional[str]] = mapped_column(String(32))
-    source_volume: Mapped[Optional[str]] = mapped_column(String(32))
-    source_page: Mapped[Optional[str]] = mapped_column(String(128))
-
-    is_active: Mapped[bool] = mapped_column(default=True)
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
-    )
-
-
-class DrugChunk(Base):
-    __tablename__ = "drug_chunks"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    drug_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
-    section: Mapped[Optional[str]] = mapped_column(String(64))
-    content: Mapped[str] = mapped_column(Text, nullable=False)
-    source: Mapped[Optional[str]] = mapped_column(Text)
-    chunk_index: Mapped[int] = mapped_column(default=0)
-    embedding: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )

+ 0 - 30
backend-python/app/models/knowledge_point.py

@@ -1,30 +0,0 @@
-from typing import Optional
-from datetime import datetime, timezone
-
-from sqlalchemy import Column, String, Text, Integer, Float, DateTime, Boolean, ForeignKey, JSON, func
-from sqlalchemy.orm import Mapped, mapped_column
-
-from app.models.user import Base
-
-
-class KnowledgePoint(Base):
-    __tablename__ = "knowledge_points"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    point_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
-    subject: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
-    chapter_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
-    chapter_name: Mapped[str] = mapped_column(String(256))
-    title: Mapped[str] = mapped_column(String(512), nullable=False)
-    content: Mapped[str] = mapped_column(Text, nullable=False)
-    difficulty: Mapped[int] = mapped_column(default=1)  # 1-5
-    frequency: Mapped[Optional[str]] = mapped_column(String(16))  # 高频/中频/低频/未考
-    related_drugs: Mapped[Optional[list]] = mapped_column(JSON)
-    source: Mapped[Optional[str]] = mapped_column(Text)
-    vector_id: Mapped[Optional[str]] = mapped_column(String(128), index=True)
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
-    )

+ 0 - 31
backend-python/app/models/question.py

@@ -1,31 +0,0 @@
-from typing import Optional
-from datetime import datetime, timezone
-
-from sqlalchemy import Column, String, Text, Integer, Float, DateTime, Boolean, JSON, func
-from sqlalchemy.orm import Mapped, mapped_column
-
-from app.models.user import Base
-
-
-class Question(Base):
-    __tablename__ = "questions"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    question_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
-    question_type: Mapped[str] = mapped_column(String(8), nullable=False)  # A / B / X
-    subject: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
-    chapter_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
-    difficulty: Mapped[int] = mapped_column(default=1)  # 1-5
-    content: Mapped[str] = mapped_column(Text, nullable=False)
-    options: Mapped[list] = mapped_column(JSON, nullable=False)
-    answer: Mapped[str] = mapped_column(String(16), nullable=False)
-    explanation: Mapped[str] = mapped_column(Text, nullable=False)
-    knowledge_point_ids: Mapped[Optional[list]] = mapped_column(JSON)
-    source: Mapped[Optional[str]] = mapped_column(Text)
-    frequency: Mapped[Optional[str]] = mapped_column(String(16))
-    audited: Mapped[bool] = mapped_column(default=False)
-    correct_count: Mapped[int] = mapped_column(default=0)
-    attempt_count: Mapped[int] = mapped_column(default=0)
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )

+ 0 - 28
backend-python/app/models/user.py

@@ -1,28 +0,0 @@
-from typing import Optional
-from datetime import datetime, timezone
-
-from sqlalchemy import (
-    Column, String, Text, Integer, Float, Boolean,
-    DateTime, ForeignKey, JSON, func,
-)
-from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
-
-
-class Base(DeclarativeBase):
-    pass
-
-
-class User(Base):
-    __tablename__ = "users"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    openid: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
-    nickname: Mapped[Optional[str]] = mapped_column(String(128))
-    avatar_url: Mapped[Optional[str]] = mapped_column(String(512))
-    role: Mapped[str] = mapped_column(String(32), default="user")  # user / admin / pharmacist
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
-    )

+ 0 - 43
backend-python/app/models/user_progress.py

@@ -1,43 +0,0 @@
-from typing import Optional
-from datetime import datetime, timezone
-
-from sqlalchemy import Column, String, Text, Integer, Float, DateTime, Boolean, ForeignKey, JSON, func
-from sqlalchemy.orm import Mapped, mapped_column
-
-from app.models.user import Base
-
-
-class UserProgress(Base):
-    __tablename__ = "user_progress"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
-    subject: Mapped[Optional[str]] = mapped_column(String(64), index=True)
-    chapter_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
-    knowledge_point_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
-    questions_answered: Mapped[int] = mapped_column(default=0)
-    questions_correct: Mapped[int] = mapped_column(default=0)
-    study_seconds: Mapped[int] = mapped_column(default=0)
-    last_study_at: Mapped[Optional[datetime]] = mapped_column(
-        DateTime(timezone=True)
-    )
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
-    )
-
-
-class AnswerRecord(Base):
-    __tablename__ = "answer_records"
-
-    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
-    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
-    question_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
-    user_answer: Mapped[str] = mapped_column(String(16), nullable=False)
-    is_correct: Mapped[bool] = mapped_column(nullable=False)
-    study_mode: Mapped[str] = mapped_column(String(32), default="practice")  # practice / exam
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime(timezone=True), server_default=func.now()
-    )

+ 0 - 0
backend-python/app/rag/__init__.py


+ 0 - 215
backend-python/app/rag/prompt.py

@@ -1,215 +0,0 @@
-"""
-多场景 Prompt 模板 — 六种意图 + 兜底
-输出顺序:结论 → 详细内容 → 来源明细 → AI 声明
-每条药典信息必须标注实际来源名称(非数字编号)
-"""
-import sys
-from pathlib import Path
-sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
-
-# ============================================================
-# 统一的合规要求(AI 声明放在最后)
-# ============================================================
-COMPLIANCE = """
-—— 段落标题【标题名】后直接换行写内容,标题与内容之间不得有空行
-—— 段落之间用一个空行分隔即可,不得连续多个空行
-—— 每条药典信息标注来源名称(如"2025版药典二部P567""维基百科"),不用数字编号
-—— 引用网络来源时必须附带完整 URL(如 https://zh.wikipedia.org/wiki/阿莫西林),方便用户直接点击查看
-—— 不得编造药典版本号、页码
-—— 通用知识标注为【通用药学知识】
-—— 回答末尾必须包含【AI 声明】段落
-"""
-
-AI_DISCLAIMER = """【AI 声明】
-本回答由 AI 生成,仅供参考,不构成处方或用药建议。
-用药前请阅读药品说明书,处方药请在医师或药师指导下使用。"""
-
-# ============================================================
-# 1. 药品信息查询
-# 回答顺序:结论 → 详细说明 → 注意事项 → 来源明细 → AI 声明
-# ============================================================
-DRUG_QUERY = f"""你是中华药典AI助手。
-
-回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
-
-【结论】
-1-2句话概括。
-【详细说明】
-- 每条信息后标注实际来源名称(如"2025版药典二部P567""维基百科")
-- 参考资料不足处标注【通用药学知识】
-【注意事项】
-禁忌、特殊人群、不良反应等
-【来源明细】
-列出本题引用的所有资料名称及出处
-{COMPLIANCE}
-{AI_DISCLAIMER}"""
-
-# ============================================================
-# 2. 用法用量 / 安全咨询
-# 回答顺序:结论 → 用法用量 → 禁忌 → 不良反应 → 注意事项 → 来源明细 → AI 声明
-# ============================================================
-USAGE_GUIDE = f"""你是中华药典用药指导助手。
-
-回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
-
-【结论】
-一句话建议。
-【用法用量】
-剂量、频次、疗程 — 标注来源名称
-【禁忌】
-标注来源名称
-【不良反应】
-标注来源名称
-【注意事项】
-孕妇/儿童/老年/肝肾不全等特殊人群提示
-【来源明细】
-列出本题引用的所有资料名称及出处
-【安全提醒】
-「请在医师或药师指导下用药」
-{COMPLIANCE}
-{AI_DISCLAIMER}"""
-
-# ============================================================
-# 3. 法规条款查询
-# 回答顺序:摘要 → 原文引用 → 条款出处 → 关联条款 → 来源明细 → AI 声明
-# ============================================================
-REGULATION = f"""你是药典法规条款查询助手。
-
-回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
-
-【摘要】
-一句话概述该条款内容。
-【原文引用】
-逐字引用原文条款,保留编号和术语原貌。
-【条款出处】
-编号 + 页码(如"2025版药典四部通则0631")
-【关联条款】
-其他相关条款编号及简要说明(如有)
-【来源明细】
-列出本题引用的所有资料名称及出处
-{COMPLIANCE}
-{AI_DISCLAIMER}"""
-
-# ============================================================
-# 4. 执业药师考试辅导
-# 回答顺序:考点定位 → 知识要点 → 记忆技巧 → 考试频率 → 来源 → AI 声明
-# ============================================================
-EXAM_TUTOR = f"""你是执业药师考试辅导助手。
-
-回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
-
-【考点定位】
-一句话定位考点(科目-章节-知识点)。
-【知识要点】
-分点讲解核心内容,标注来源名称。教学延伸标注【教学补充,非药典原文】。
-【记忆技巧】
-口诀、对比表格、联想记忆等。
-【考试频率】
-高频 / 中频 / 低频。
-【来源明细】
-大纲章节 + 药典出处 + 参考资料名称
-{COMPLIANCE}
-{AI_DISCLAIMER}"""
-
-# ============================================================
-# 5. 症状用药建议
-# 回答顺序:病情评估 → 用药方案 → 非药物建议 → 注意事项 → 就医指征 → 来源明细 → AI 声明
-# ============================================================
-SYMPTOM_ADVICE = f"""你是AI药典用药助手。用户描述症状寻求用药建议。
-
-⚠️ 安全规则(最高优先级):
-—— 如果用户在描述【已知的药物过敏】(如"青霉素过敏""头孢过敏"),
-   这不是症状求药,而是药物安全咨询。此时应当:
-   ① 说明该药物过敏的处理方法(停药、抗组胺药、肾上腺素等)
-   ② 列出安全的替代药物选择
-   ③ 强调就医指征
-   【禁止】推荐任何新药来"治疗"该过敏症状本身。
-—— 检索资料中如无相关临床指导内容,必须标注【通用药学知识】,不得编造用药方案。
-—— 禁止编造检索资料中不存在的药品名称和用量。
-
-回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
-
-【病情评估】
-严重程度判断 + 是否需要立即就医。
-【用药方案】
-方案一:药品名 — 用量(来源名称)
-方案二:药品名 — 用量(来源名称)
-每个方案后标注来源(如"2025版药典""维基百科""通用药学知识")
-【非药物建议】
-休息、饮食、物理方法等
-【注意事项】
-- 各方案的禁忌人群
-- 药物相互作用
-- 特殊人群提示(孕妇/儿童/老年)
-【就医指征】
-列出必须就医的警示信号(如体温>39°C持续、呼吸困难等)
-【来源明细】
-列出引用的所有资料名称
-【免责声明】
-⚠️ 本回答为AI用药参考,不构成处方建议,请以药品说明书及医师指导为准。
-{COMPLIANCE}
-{AI_DISCLAIMER}"""
-
-# ============================================================
-# 6. 无资料兜底
-# 回答顺序:结论 → 通用知识 → 来源说明 → AI 声明
-# ============================================================
-NO_DOCS = f"""你是AI药典助手。知识库中未检索到与用户问题直接相关的药典原文。
-
-严格规则:
-1. 首行加:⚠️ 此回答未基于中国药典原文,为AI通用药学参考
-2. 所有用药建议必须同时列明禁忌和注意事项
-3. 不得使用"药典规定""药典记载"等用语
-4. 不得编造任何来源标注、页码、版本号
-5. 不得推荐未在中国获批的药品
-6. 若无法确定安全答案,直接建议就医
-7. 每条信息标注为【通用药学知识】
-
-回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
-
-【结论】
-一句话回答用户问题。
-【详细说明】
-基于通用药学知识作答,每条标注【通用药学知识】。
-【来源说明】
-声明:上述内容来源为AI通用药学知识库,非《中国药典》原文。
-{COMPLIANCE}
-{AI_DISCLAIMER}"""
-
-
-PROMPT_MAP = {
-    "drug_query":    DRUG_QUERY,
-    "usage_guide":   USAGE_GUIDE,
-    "regulation":    REGULATION,
-    "exam_tutor":    EXAM_TUTOR,
-    "symptom_advice": SYMPTOM_ADVICE,
-    "no_docs":       NO_DOCS,
-}
-
-
-def build_prompt(query: str, documents: list[dict], intent: str = "drug_query") -> list[dict]:
-    if not documents:
-        system_prompt = NO_DOCS
-        user_message = f"""【用户问题】{query}
-
-(提示:知识库中未找到与该问题直接相关的药典原文。请严格使用通用药学知识作答,不得标注任何药典来源。)"""
-    else:
-        system_prompt = PROMPT_MAP.get(intent, NO_DOCS)
-        source_lines = []
-        for i, d in enumerate(documents):
-            src = d.get('source', '未知来源')
-            txt = d.get('content', d.get('text', ''))
-            source_lines.append(f"【资料{i+1}:{src}】\n{txt}")
-        context = "\n\n---\n\n".join(source_lines)
-        user_message = f"""【参考资料】共 {len(documents)} 条:
-{context}
-
-【引用要求】
-每条来源于参考资料的信息,必须标注该资料的实际名称(如"2025版药典二部P567""维基百科""通用药学知识"),不得使用数字编号。
-
-【用户问题】{query}"""
-
-    return [
-        {"role": "system", "content": system_prompt},
-        {"role": "user", "content": user_message},
-    ]

+ 0 - 99
backend-python/app/rag/reranker.py

@@ -1,99 +0,0 @@
-"""
-BGE-Reranker-v2-m3 重排序
-Cross-Encoder 精排,将 Top-20 精准压缩到 Top-5
-在当前未加载 Cross-Encoder 模型的情况下,提供基于信号融合的精排:
-  - 最低相似度阈值过滤
-  - 关键词重叠度加权
-  - 内容去重
-"""
-import re
-from typing import Optional
-from app.core.config import get_settings
-
-settings = get_settings()
-
-# 最低相似度阈值:低于此分数的结果很可能不相关,直接丢弃
-MIN_SIMILARITY_THRESHOLD = 0.3
-
-
-class Reranker:
-    def __init__(self, model_name: Optional[str] = None):
-        self.model_name = model_name or settings.reranker_model
-        self._model = None
-
-    def _load_model(self):
-        """Phase 3 实现:加载 BGE-Reranker-v2-m3 Cross-Encoder"""
-        pass
-
-    def rerank(
-        self,
-        query: str,
-        documents: list[dict],
-        top_k: int = 5,
-    ) -> list[dict]:
-        """重排序:融合向量相似度 + 关键词重叠度,过滤低质量结果并去重。"""
-        if not documents:
-            return []
-
-        # 1. 过滤:低于阈值的结果直接丢弃(避免答非所问)
-        filtered = [d for d in documents if d.get("score", 0) >= MIN_SIMILARITY_THRESHOLD]
-
-        # 2. 关键词加权:查询词在文档中出现越多,得分越高
-        query_terms = self._tokenize_query(query)
-        for doc in filtered:
-            content = doc.get("content", "")
-            keyword_bonus = self._keyword_overlap_score(query_terms, content)
-            # 原始相似度 + 关键词奖励(关键词匹配最高加 0.3)
-            doc["score"] = doc.get("score", 0) + keyword_bonus * 0.3
-
-        # 3. 按融合分数排序
-        sorted_docs = sorted(filtered, key=lambda d: d.get("score", 0), reverse=True)
-
-        # 4. 去重:移除内容高度重叠的 chunk(Jaccard 相似度 > 0.8)
-        deduped = []
-        seen_texts = []
-        for doc in sorted_docs:
-            content = doc.get("content", "")
-            if self._is_duplicate(content, seen_texts, threshold=0.8):
-                continue
-            deduped.append(doc)
-            seen_texts.append(content)
-
-        return deduped[:top_k]
-
-    def _tokenize_query(self, query: str) -> set[str]:
-        """提取查询中的关键词(中文按 1-4 字切分,英文按空格切分)。"""
-        tokens = set()
-        # 中文:提取 2-4 字短语
-        for n in range(2, 5):
-            for i in range(len(query) - n + 1):
-                seg = query[i:i + n]
-                if all('一' <= c <= '鿿' for c in seg):
-                    tokens.add(seg)
-        # 英文/数字词
-        for word in re.findall(r'[a-zA-Z0-9]+', query):
-            tokens.add(word.lower())
-        return tokens
-
-    def _keyword_overlap_score(self, query_terms: set[str], content: str) -> float:
-        """计算查询词在文档中的覆盖率(0.0 ~ 1.0)。"""
-        if not query_terms:
-            return 0.0
-        matched = sum(1 for t in query_terms if t in content)
-        return matched / len(query_terms)
-
-    def _is_duplicate(self, content: str, seen_texts: list[str], threshold: float = 0.8) -> bool:
-        """检查当前内容是否与已选中的内容高度重复(基于 Jaccard 字符集相似度)。"""
-        if not content or not seen_texts:
-            return False
-        # 采样前 200 字符做快速比较
-        content_sample = set(content[:200])
-        if not content_sample:
-            return False
-        for seen in seen_texts[-5:]:  # 只比较最近 5 个
-            seen_sample = set(seen[:200])
-            intersection = len(content_sample & seen_sample)
-            union = len(content_sample | seen_sample)
-            if union > 0 and intersection / union > threshold:
-                return True
-        return False

+ 0 - 183
backend-python/app/rag/retriever.py

@@ -1,183 +0,0 @@
-"""
-混合检索器:pgvector 向量检索 + BM25 关键词检索
-"""
-import os
-import re
-import json
-from typing import Optional
-
-import httpx
-from sqlalchemy import text
-from sqlalchemy.ext.asyncio import create_async_engine
-
-from app.core.config import get_settings
-
-settings = get_settings()
-
-EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
-EMBEDDING_MODEL = "text-embedding-v3"
-DB_URL = settings.database_url
-
-
-def classify_intent(query: str) -> str:
-    """基于关键词的意图分类,含否定语义处理和置信度兜底。"""
-    q = query.strip()
-
-    # --- 已知药物过敏检测:XX过敏处理/怎么办/急救/替代 → 用药安全咨询 ---
-    # 必须在 symptom_keywords 之前检测,否则会被 "过敏" 误判为症状求药
-    known_allergy_drugs = [
-        "青霉素", "头孢", "磺胺", "阿莫西林", "布洛芬",
-        "阿司匹林", "链霉素", "庆大霉素", "四环素",
-        "红霉素", "氯霉素", "万古霉素", "喹诺酮",
-        "普鲁卡因", "利多卡因", "碘", "破伤风",
-    ]
-    if "过敏" in q and any(drug in q for drug in known_allergy_drugs):
-        return "usage_guide"
-
-    # --- 已知药物过敏通用模式:对XX过敏 / XX药过敏 / 药物过敏 ---
-    known_allergy_patterns = [
-        r"对.{1,6}过敏",           # 对青霉素过敏
-        r".{1,6}药过敏",           # 头孢药过敏
-        r"药物过敏",               # 药物过敏
-        r".+过敏(处理|怎么办|急救|替代|注意|救治|抢救|应急)",
-    ]
-    if any(re.search(pat, q) for pat in known_allergy_patterns):
-        return "usage_guide"
-
-    # --- 否定语义检测:先检查是否包含否定模式 ---
-    negation_patterns = [
-        r"不是", r"没有", r"并非", r"不算", r"不属于",
-        r"这不是", r"我没有", r"不包含", r"不涉及",
-    ]
-    has_negation = any(re.search(pat, q) for pat in negation_patterns)
-
-    # 如果有否定语义,直接返回药品查询(用户可能在排除某些情况)
-    if has_negation:
-        return "drug_query"
-
-    # --- 关键词定义 ---
-    usage_keywords = [
-        "怎么吃", "吃多少", "怎么用", "一天几次", "多长时间",
-        "能一起吃", "孕妇能用", "儿童用量", "哺乳期",
-        "饭前还是饭后", "空腹", "过量", "漏服", "停药",
-        "副作用多大", "伤肝吗", "伤肾吗", "安全吗",
-    ]
-    safety_sections = [
-        "副作用", "不良反应", "禁忌", "注意事项",
-        "能不能", "可以吗", "会不会",
-    ]
-    regulation_keywords = [
-        "凡例", "通则规定", "制剂通则",
-        "一般规定", "通用技术要求", "检验方法通则",
-    ]
-    exam_keywords = [
-        "执业药师考试", "考点", "历年真题", "考试大纲",
-        "高频考点", "报名时间",
-    ]
-    # 症状/疾病求药关键词
-    symptom_keywords = [
-        "吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
-        "买什么药", "推荐什么药", "用什么药", "用药建议",
-        "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛",
-        "嗓子疼", "流鼻涕", "鼻塞", "肚子疼", "胃疼",
-        "过敏", "皮肤痒", "失眠", "便秘", "牙疼",
-        "体温", "多少度", "退烧", "止痛", "止泻",
-    ]
-
-    # --- 优先级匹配(usage > symptom > regulation > exam > drug_query) ---
-    # 注意:"过敏"从 safety_sections 中移除,只保留在 symptom_keywords,
-    # 避免"过敏"被误判为用法询问而非症状求药。
-    if any(kw in q for kw in usage_keywords):
-        return "usage_guide"
-    if any(kw in q for kw in safety_sections):
-        return "usage_guide"
-    if any(kw in q for kw in symptom_keywords):
-        return "symptom_advice"
-    if any(kw in q for kw in regulation_keywords):
-        return "regulation"
-    if any(kw in q for kw in exam_keywords):
-        return "exam_tutor"
-
-    # 兜底:无明确意图时走药品通用查询(向量检索命中率最高)
-    return "drug_query"
-
-
-async def _get_query_embedding(query: str) -> list[float]:
-    api_key = os.environ.get("QWEN_API_KEY") or settings.qwen_api_key
-    async with httpx.AsyncClient(timeout=30) as client:
-        resp = await client.post(
-            EMBEDDING_URL,
-            headers={
-                "Content-Type": "application/json",
-                "Authorization": f"Bearer {api_key}",
-            },
-            json={
-                "model": EMBEDDING_MODEL,
-                "input": {"texts": [query]},
-                "parameters": {"text_type": "query"},
-            },
-        )
-        data = resp.json()
-    if data.get("code") and data.get("code") != "":
-        raise RuntimeError(f"Embedding error: {data.get('message')}")
-    return data["output"]["embeddings"][0]["embedding"]
-
-
-class MixedRetriever:
-    def __init__(self):
-        self.table_name = "drug_chunks"
-        self.vector_dim = settings.embedding_dim
-        self._engine = None
-
-    @property
-    def engine(self):
-        """延迟创建数据库引擎,复用连接池。"""
-        if self._engine is None:
-            self._engine = create_async_engine(
-                DB_URL,
-                pool_size=5,
-                max_overflow=10,
-                pool_pre_ping=True,
-            )
-        return self._engine
-
-    async def close(self):
-        """释放数据库连接池。"""
-        if self._engine is not None:
-            await self._engine.dispose()
-            self._engine = None
-
-    async def search(
-        self,
-        query: str,
-        intent: str = "drug_query",
-        top_k: int = 20,
-        filters: Optional[dict] = None,
-    ) -> list[dict]:
-        query_vec = await _get_query_embedding(query)
-        vec_str = "[" + ",".join(str(v) for v in query_vec) + "]"
-
-        async with self.engine.connect() as conn:
-            result = await conn.execute(
-                text("""
-                    SELECT content, source, drug_id, section,
-                           1 - (vec <=> CAST(:qv AS vector)) AS similarity
-                    FROM drug_chunks
-                    WHERE vec IS NOT NULL
-                    ORDER BY vec <=> CAST(:qv AS vector)
-                    LIMIT :k
-                """),
-                {"qv": vec_str, "k": top_k},
-            )
-            rows = result.fetchall()
-
-        docs = []
-        for row in rows:
-            docs.append({
-                "content": row[0],
-                "source": row[1],
-                "drug_id": row[2],
-                "section": row[3],
-                "score": float(row[4]),
-            })
-        return docs

+ 0 - 27
backend-python/app/rag/retriever_exam.py

@@ -1,27 +0,0 @@
-"""
-考试场景专门检索器
-与通用检索器的区别:
-  1. 支持按科目/章节过滤
-  2. 检索权重偏向教学解释型内容
-  3. 优先匹配大纲知识点
-"""
-from typing import Optional
-
-
-class ExamRetriever:
-    def __init__(self):
-        self.collection_name = "exam_knowledge"
-
-    async def search(
-        self,
-        query: str,
-        subject: Optional[str] = None,
-        chapter_id: Optional[str] = None,
-        top_k: int = 10,
-    ) -> list[dict]:
-        filters = {}
-        if subject:
-            filters["subject"] = subject
-        if chapter_id:
-            filters["chapter_id"] = chapter_id
-        return []

+ 0 - 17
backend-python/requirements.txt

@@ -1,17 +0,0 @@
-fastapi>=0.115.0
-uvicorn[standard]>=0.30.0
-sqlalchemy[asyncio]>=2.0.30
-asyncpg>=0.29.0
-pgvector>=0.3.0
-alembic>=1.13.0
-pydantic>=2.7.0
-pydantic-settings>=2.3.0
-redis>=5.0.0
-python-jose[cryptography]>=3.3.0
-passlib[bcrypt]>=1.7.4
-httpx>=0.27.0
-openai>=1.30.0
-sentence-transformers>=2.2.0
-FlagEmbedding>=1.2.0
-langchain>=0.3.0
-langchain-community>=0.3.0