瀏覽代碼

fix(langgraph): 从 git 历史恢复最小可运行集(config/rag/llm/middleware)

Xiaogang Liao 1 月之前
父節點
當前提交
eafb48c721

+ 8 - 0
cfc-langgraph/app/api/health.py

@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health")
+async def health():
+    return {"status": "ok"}

+ 54 - 0
cfc-langgraph/app/config.py

@@ -0,0 +1,54 @@
+from pydantic_settings import BaseSettings
+from typing import Optional
+
+
+class Settings(BaseSettings):
+    # LLM
+    llm_api_key: str
+    llm_base_url: str = "https://api.deepseek.com/v1"
+    llm_model: str = "deepseek-chat"
+    # 部分模型(如 kimi-k2.6)仅允许 temperature=1,统一从配置读取便于切换模型时调整
+    llm_temperature: float = 1.0
+
+    # Embedding
+    embedding_api_key: Optional[str] = None
+    embedding_base_url: Optional[str] = None
+    embedding_model: str = "text-embedding-v3"
+
+    # Java Backend
+    java_base_url: str = "http://localhost:9082"
+    java_context_url: Optional[str] = None
+
+    # LangSmith
+    langchain_tracing_v2: bool = False
+    langchain_api_key: Optional[str] = None
+    langchain_project: str = "cfc-langgraph"
+
+    # Service
+    service_host: str = "0.0.0.0"
+    service_port: int = 9000
+    log_level: str = "info"
+
+    # Dify Fallback
+    dify_base_url: Optional[str] = None
+    dify_tongue_api_key: Optional[str] = None
+
+    # Chroma
+    chroma_db_path: str = "./data/chroma_db"
+
+    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+
+    @property
+    def effective_embedding_api_key(self) -> str:
+        return self.embedding_api_key or self.llm_api_key
+
+    @property
+    def effective_embedding_base_url(self) -> str:
+        return self.embedding_base_url or self.llm_base_url
+
+    @property
+    def effective_java_context_url(self) -> str:
+        return self.java_context_url or f"{self.java_base_url}/api/ai/context"
+
+
+settings = Settings()

+ 44 - 0
cfc-langgraph/app/log_config.py

@@ -0,0 +1,44 @@
+import logging
+import json
+import sys
+from datetime import datetime, timezone
+
+
+class JsonFormatter(logging.Formatter):
+    """JSON 日志格式化器 (适合生产环境日志聚合)"""
+
+    def format(self, record: logging.LogRecord) -> str:
+        log_entry = {
+            "timestamp": datetime.now(timezone.utc).isoformat(),
+            "level": record.levelname,
+            "logger": record.name,
+            "message": record.getMessage(),
+        }
+        if hasattr(record, "trace_id"):
+            log_entry["trace_id"] = record.trace_id
+        if record.exc_info and record.exc_info[0]:
+            log_entry["exception"] = self.formatException(record.exc_info)
+        return json.dumps(log_entry, ensure_ascii=False)
+
+
+def setup_logging(level: str = "INFO", json_format: bool = False):
+    root = logging.getLogger()
+    root.setLevel(getattr(logging, level.upper(), logging.INFO))
+
+    root.handlers.clear()
+
+    handler = logging.StreamHandler(sys.stdout)
+
+    if json_format:
+        handler.setFormatter(JsonFormatter())
+    else:
+        handler.setFormatter(logging.Formatter(
+            "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+            datefmt="%Y-%m-%d %H:%M:%S",
+        ))
+
+    root.addHandler(handler)
+
+    logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("chromadb").setLevel(logging.WARNING)
+    logging.getLogger("langchain").setLevel(logging.WARNING)

+ 81 - 0
cfc-langgraph/app/main.py

@@ -0,0 +1,81 @@
+import os
+import time
+import asyncio
+import logging
+from fastapi import FastAPI, Request
+from app.api import health
+# 以下模块文件被 f18dd86e 清空(0字节),恢复前注释避免 import 失败
+# from app.api import recommend, chat, analyze, tongue, adapter, report_parse, meal
+from app import monitoring
+from src.app import router as questionnaire_router
+
+logger = logging.getLogger(__name__)
+
+app = FastAPI(title="cfc-langgraph", version="0.3.0")
+
+app.include_router(health.router)
+# app.include_router(recommend.router)
+# app.include_router(chat.router)
+# app.include_router(analyze.router)
+# app.include_router(tongue.router)
+# app.include_router(adapter.router)
+app.include_router(monitoring.router)
+app.include_router(questionnaire_router)
+# app.include_router(report_parse.router)
+# app.include_router(meal.router)
+
+
+@app.middleware("http")
+async def timing_middleware(request: Request, call_next):
+    start = time.perf_counter()
+    response = await call_next(request)
+    elapsed = time.perf_counter() - start
+
+    if elapsed > 5:
+        logger.warning("SLOW_REQUEST: %s %s took %.2fs",
+                       request.method, request.url.path, elapsed)
+    else:
+        logger.debug("REQUEST: %s %s took %.2fs",
+                     request.method, request.url.path, elapsed)
+
+    response.headers["X-Response-Time"] = f"{elapsed:.3f}s"
+    return response
+
+
+@app.on_event("startup")
+async def startup():
+    from app.config import settings
+    from app.log_config import setup_logging
+
+    json_logs = os.getenv("JSON_LOGS", "false").lower() == "true"
+    setup_logging(level=settings.log_level, json_format=json_logs)
+
+    if os.getenv("LANGCHAIN_TRACING_V2", "").lower() == "true":
+        logger.info(
+            "LangSmith 已启用: project=%s, api_key=%s...",
+            settings.langchain_project,
+            settings.langchain_api_key[:8] if settings.langchain_api_key else "none",
+        )
+
+    from app.rag.retriever import RagRetriever
+    retriever = RagRetriever()
+    await retriever.initialize()
+
+    async def schedule_kb_sync():
+        while True:
+            try:
+                from app.tasks.knowledge_sync import sync_knowledge_base
+                await sync_knowledge_base()
+            except Exception as e:
+                logger.warning("知识库同步失败: %s", e)
+            await asyncio.sleep(3600)
+
+    asyncio.create_task(schedule_kb_sync())
+    logger.info("知识库定时同步已启动 (间隔: 3600s)")
+
+
+@app.on_event("shutdown")
+async def shutdown():
+    from app.tools.java_client import JavaClient
+    client = JavaClient()
+    await client.close()

+ 1 - 0
cfc-langgraph/app/memory/__init__.py

@@ -0,0 +1 @@
+from .store import MemoryManager

+ 60 - 0
cfc-langgraph/app/memory/store.py

@@ -0,0 +1,60 @@
+import os
+import logging
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+
+class MemoryManager:
+    """轻量级记忆管理器 - 仅保存对话历史到向量数据库
+    
+    不再使用 langchain.memory(已废弃),直接实现核心功能:
+    - Layer 3 - 语义记忆:每次对话后向量化存储,供后续会话召回
+    """
+
+    def __init__(self, embeddings):
+        """初始化记忆管理器
+        
+        Args:
+            embeddings: 嵌入模型实例 (OpenAIEmbeddings 或其他兼容接口)
+        """
+        self.embeddings = embeddings
+        # Layer 3 向量库:历史对话记忆
+        from langchain_chroma import Chroma
+        self.memory_vectorstore = Chroma(
+            collection_name="user_memory",
+            embedding_function=self.embeddings,
+            persist_directory=os.path.expanduser("~/.cfc/langgraph/memory"),
+        )
+
+    async def save_conversation(self, user_id: int, conversation_id: str,
+                                messages: list[dict]):
+        """会话结束后保存到向量记忆库"""
+        texts = []
+        for msg in messages:
+            role = msg.get("role", "unknown")
+            content = msg.get("content", "")
+            texts.append(f"[{role}] {content}")
+
+        full_text = "\n".join(texts)
+        metadata = {
+            "user_id": user_id,
+            "conversation_id": conversation_id,
+            "timestamp": str(datetime.now()),
+        }
+
+        await self.memory_vectorstore.aadd_texts(
+            texts=[full_text],
+            metadatas=[metadata],
+        )
+        self.memory_vectorstore.persist()
+        logger.info("已保存对话到向量记忆:conv=%s, user=%s", conversation_id, user_id)
+
+    async def recall(self, user_id: int, query: str, k: int = 3) -> list[str]:
+        """语义召回:查询与 query 最相似的历史对话片段"""
+        results = self.memory_vectorstore.similarity_search(
+            query,
+            k=k,
+            filter={"user_id": user_id},
+        )
+        return [doc.page_content for doc in results]

+ 137 - 0
cfc-langgraph/app/monitoring.py

@@ -0,0 +1,137 @@
+"""Prometheus 监控指标"""
+from prometheus_client import Counter, Histogram, Gauge, generate_latest
+from fastapi import APIRouter, Response
+import time
+import functools
+
+
+llm_calls_total = Counter(
+    "llm_calls_total", "Total LLM API calls",
+    ["model", "status"],
+)
+llm_duration_seconds = Histogram(
+    "llm_duration_seconds", "LLM call duration",
+    ["model"],
+    buckets=(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0),
+)
+llm_tokens_total = Counter(
+    "llm_tokens_total", "Total tokens used",
+    ["model", "type"],
+)
+
+rag_retrievals_total = Counter(
+    "rag_retrievals_total", "Total RAG retrievals",
+    ["method"],
+)
+rag_duration_seconds = Histogram(
+    "rag_duration_seconds", "RAG retrieval duration",
+    ["method"],
+    buckets=(0.01, 0.05, 0.1, 0.5, 1.0),
+)
+
+agent_calls_total = Counter(
+    "agent_calls_total", "Total Agent invocations",
+    ["agent_type"],
+)
+agent_duration_seconds = Histogram(
+    "agent_duration_seconds", "Agent execution duration",
+    ["agent_type"],
+    buckets=(0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0),
+)
+
+java_calls_total = Counter(
+    "java_calls_total", "Total calls to Java backend",
+    ["endpoint", "status"],
+)
+java_duration_seconds = Histogram(
+    "java_duration_seconds", "Java backend call duration",
+    ["endpoint"],
+    buckets=(0.01, 0.05, 0.1, 0.5, 1.0, 2.0),
+)
+
+kb_sync_duration = Gauge(
+    "kb_sync_duration_seconds", "Last knowledge base sync duration"
+)
+kb_sync_documents = Gauge(
+    "kb_sync_documents_total", "Documents processed in last sync"
+)
+
+
+def monitor_agent(agent_type: str):
+    """Agent 性能监控装饰器"""
+    def decorator(func):
+        @functools.wraps(func)
+        async def wrapper(*args, **kwargs):
+            agent_calls_total.labels(agent_type=agent_type).inc()
+            start = time.perf_counter()
+            try:
+                result = await func(*args, **kwargs)
+                agent_duration_seconds.labels(agent_type=agent_type).observe(
+                    time.perf_counter() - start)
+                return result
+            except Exception as e:
+                agent_duration_seconds.labels(agent_type=agent_type).observe(
+                    time.perf_counter() - start)
+                raise
+        return wrapper
+    return decorator
+
+
+router = APIRouter(tags=["monitoring"])
+
+
+@router.get("/metrics")
+async def metrics():
+    return Response(
+        content=generate_latest(),
+        media_type="text/plain; charset=utf-8",
+    )
+
+
+@router.get("/api/v1/health")
+async def detailed_health():
+    """详细健康检查 (含组件状态)"""
+    from app.config import settings
+    status = {"status": "ok", "components": {}}
+
+    try:
+        import os
+        chroma_path = settings.chroma_db_path
+        status["components"]["chromadb"] = {
+            "status": "ok",
+            "path": chroma_path,
+            "exists": os.path.exists(chroma_path),
+        }
+    except Exception as e:
+        status["components"]["chromadb"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    try:
+        from langchain_openai import ChatOpenAI
+        llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            max_tokens=5,
+        )
+        await llm.ainvoke("ping")
+        status["components"]["llm"] = {"status": "ok"}
+    except Exception as e:
+        status["components"]["llm"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    try:
+        from app.tools.java_client import JavaClient
+        client = JavaClient()
+        jc = await client._get_client()
+        resp = await jc.get("/health")
+        if resp.status_code == 200:
+            status["components"]["java_backend"] = {"status": "ok"}
+        else:
+            status["components"]["java_backend"] = {"status": "error", "code": resp.status_code}
+            status["status"] = "degraded"
+    except Exception as e:
+        status["components"]["java_backend"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    return status

+ 2 - 0
cfc-langgraph/app/rag/__init__.py

@@ -0,0 +1,2 @@
+from .embeddings import get_embeddings
+from .retriever import RagRetriever

+ 15 - 0
cfc-langgraph/app/rag/embeddings.py

@@ -0,0 +1,15 @@
+from langchain_openai import OpenAIEmbeddings
+from app.config import settings
+
+_embeddings = None
+
+
+def get_embeddings():
+    global _embeddings
+    if _embeddings is None:
+        _embeddings = OpenAIEmbeddings(
+            model=settings.embedding_model,
+            api_key=settings.effective_embedding_api_key,
+            base_url=settings.effective_embedding_base_url,
+        )
+    return _embeddings

+ 49 - 0
cfc-langgraph/app/rag/loader.py

@@ -0,0 +1,49 @@
+from app.tools.java_client import JavaClient
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class KnowledgeLoader:
+    """知识库加载器: 从 Java 侧拉取文章并格式化"""
+
+    def __init__(self):
+        self.java = JavaClient()
+
+    async def load_all_articles(self) -> list[dict]:
+        """获取所有已发布文章"""
+        return await self.java.get_published_articles()
+
+    async def load_updated_since(self, since: str) -> list[dict]:
+        """增量获取: 获取某个时间后更新的文章"""
+        try:
+            client = await self.java._get_client()
+            resp = await client.post("/api/article/updated-since", json={
+                "since": since,
+                "status": "published",
+            })
+            data = resp.json()
+            if data.get("code") == 200:
+                return data.get("data", [])
+        except Exception as e:
+            logger.warning("增量获取文章失败: %s", e)
+        return []
+
+    def format_for_indexing(self, articles: list[dict]) -> list[dict]:
+        """将文章格式化为可索引的文档"""
+        docs = []
+        for article in articles:
+            content = f"{article.get('title', '')}\n\n{article.get('summary', '')}\n\n{article.get('content', '')}"
+            docs.append({
+                "id": f"article_{article['id']}",
+                "content": content,
+                "metadata": {
+                    "source": "article",
+                    "article_id": article["id"],
+                    "title": article.get("title", ""),
+                    "tags": article.get("tags", ""),
+                    "updated_at": article.get("updatedAt", ""),
+                },
+            })
+        return docs

+ 87 - 0
cfc-langgraph/app/rag/retriever.py

@@ -0,0 +1,87 @@
+from langchain_chroma import Chroma
+from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from app.config import settings
+from app.tools.java_client import JavaClient
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class RagRetriever:
+    """简单的向量检索器 - 基于 ChromaDB"""
+
+    def __init__(self, collection_name: str = "cfc_knowledge"):
+        embeddings = OpenAIEmbeddings(
+            model=settings.embedding_model,
+            api_key=settings.effective_embedding_api_key,
+            base_url=settings.effective_embedding_base_url,
+        )
+        self.vectorstore = Chroma(
+            collection_name=collection_name,
+            embedding_function=embeddings,
+            persist_directory=settings.chroma_db_path,
+        )
+        self.java_client = JavaClient()
+
+    async def initialize(self):
+        """从 Java 侧拉取知识库,更新到向量库"""
+        try:
+            articles = await self.java_client.get_published_articles()
+            if articles:
+                # 构建文本和内容元数据
+                texts = [f"{a['title']} {a.get('summary', '')} {' '.join(a.get('tags', []))}" 
+                        for a in articles]
+                metadatas = [
+                    {
+                        "id": a["id"],
+                        "title": a["title"],
+                        "summary": a.get("summary", ""),
+                        "tags": a.get("tags", []),
+                        "type": "article",
+                    }
+                    for a in articles
+                ]
+
+                # 添加或更新向量数据库
+                await self.vectorstore.aadd_texts(texts=texts, metadatas=metadatas)
+                logger.info("知识库向量化完成:%d 篇文章", len(articles))
+        except Exception as e:
+            logger.warning("知识库初始化失败:%s", e)
+
+    async def retrieve(
+        self,
+        query: str,
+        filters: Optional[dict] = None,
+        k: int = 5,
+    ) -> list[dict]:
+        """向量相似度检索"""
+        results = []
+        
+        # 向量检索
+        filter_query = {"user_id": filters["user_id"]} if filters and "user_id" in filters else None
+        
+        docs = self.vectorstore.similarity_search(
+            query,
+            k=k * 2,  # 多取一些
+            filter=filter_query,
+        )
+        
+        # 格式化结果并去重
+        seen = set()
+        for doc in docs:
+            content_hash = hash(doc.page_content[:100])
+            if content_hash in seen:
+                continue
+            seen.add(content_hash)
+            
+            results.append({
+                "content": doc.page_content,
+                "metadata": doc.metadata,
+                "score": doc.metadata.get("score", 0) if hasattr(doc, "metadata") else 0,
+            })
+            
+            if len(results) >= k:
+                break
+        
+        return results[:k]

+ 21 - 0
cfc-langgraph/app/rag/splitter.py

@@ -0,0 +1,21 @@
+from langchain_text_splitters import RecursiveCharacterTextSplitter
+
+
+def get_knowledge_splitter():
+    """知识库文档分块器"""
+    return RecursiveCharacterTextSplitter(
+        chunk_size=500,
+        chunk_overlap=50,
+        separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
+        length_function=len,
+    )
+
+
+def get_summary_splitter():
+    """摘要分块器 (用于对话记忆)"""
+    return RecursiveCharacterTextSplitter(
+        chunk_size=1000,
+        chunk_overlap=100,
+        separators=["\n\n", "\n", "。", " ", ""],
+        length_function=len,
+    )

+ 1 - 0
cfc-langgraph/app/tools/__init__.py

@@ -0,0 +1 @@
+from .java_client import JavaClient

+ 10 - 0
cfc-langgraph/requirements.txt

@@ -0,0 +1,10 @@
+fastapi>=0.110,<1.0
+uvicorn[standard]>=0.29,<1.0
+langgraph>=0.2,<1.0
+langchain-openai>=0.2,<1.0
+langchain-core>=0.3,<1.0
+pydantic>=2.0,<3.0
+python-dotenv>=1.0,<2.0
+httpx>=0.27,<1.0
+pytest>=8.0,<9.0
+PyPDF2>=3.0,<4.0

+ 49 - 0
cfc-langgraph/src/app.py

@@ -0,0 +1,49 @@
+import json
+from datetime import datetime
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from fastapi import APIRouter, FastAPI
+from fastapi.responses import JSONResponse
+
+from .schemas.questionnaire import GenerateRequest, GenerateResponse
+from .graphs.questionnaire import get_questionnaire_graph
+
+router = APIRouter(prefix="/api/v1", tags=["questionnaire"])
+
+
+@router.post("/questionnaire/generate")
+async def generate_questionnaire(req: GenerateRequest):
+    graph = get_questionnaire_graph()
+    try:
+        result = graph.invoke({
+            "request": req,
+            "raw_response": "",
+            "questionnaire": None,
+            "error": None,
+        })
+        if result.get("error"):
+            return JSONResponse(
+                status_code=500,
+                content={"error": result["error"]}
+            )
+        version = datetime.now().strftime("%Y%m%d%H%M%S")
+        return GenerateResponse(
+            questionnaire_json=json.dumps(result["questionnaire"], ensure_ascii=False),
+            version=version,
+        )
+    except Exception as e:
+        return JSONResponse(
+            status_code=500,
+            content={"error": f"graph 执行失败: {str(e)}"}
+        )
+
+
+app = FastAPI(title="CFC LangGraph 问卷生成服务")
+app.include_router(router)
+
+
+@app.get("/health")
+def health():
+    return {"status": "ok"}

+ 71 - 0
cfc-langgraph/src/graphs/questionnaire.py

@@ -0,0 +1,71 @@
+import json
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import HumanMessage
+
+from ..llm.client import get_llm
+from ..prompts.questionnaire import PARENT_TEMPLATE, CHILD_TEMPLATE
+from ..schemas.questionnaire import Questionnaire, GenerateRequest
+
+
+class GraphState(TypedDict):
+    request: GenerateRequest
+    _prompt: Optional[str]
+    raw_response: str
+    questionnaire: Optional[dict]
+    error: Optional[str]
+
+
+def build_prompt(state: GraphState) -> GraphState:
+    req = state["request"]
+    template = PARENT_TEMPLATE if req.relationship_type == "parent" else CHILD_TEMPLATE
+    prompt = template.format(member_name=req.member_name)
+    return {**state, "_prompt": prompt}
+
+
+def call_llm(state: GraphState) -> GraphState:
+    llm = get_llm()
+    messages = [HumanMessage(content=state["_prompt"])]
+    response = llm.invoke(messages)
+    return {**state, "raw_response": response.content}
+
+
+def validate(state: GraphState) -> GraphState:
+    raw = state["raw_response"]
+    # 提取 JSON 块
+    text = raw.strip()
+    if "```json" in text:
+        text = text.split("```json")[1].split("```")[0].strip()
+    elif "```" in text:
+        text = text.split("```")[1].split("```")[0].strip()
+
+    try:
+        data = json.loads(text)
+        q = Questionnaire(**data)
+        if not q.validate_structure():
+            return {**state, "error": "问卷结构校验失败:至少需要1题且每题有options或scale"}
+        return {**state, "questionnaire": data, "error": None}
+    except Exception as e:
+        return {**state, "error": f"JSON 解析失败: {str(e)}"}
+
+
+def build_questionnaire_graph():
+    graph = StateGraph(GraphState)
+    graph.add_node("build_prompt", build_prompt)
+    graph.add_node("call_llm", call_llm)
+    graph.add_node("validate", validate)
+    graph.add_edge(START, "build_prompt")
+    graph.add_edge("build_prompt", "call_llm")
+    graph.add_edge("call_llm", "validate")
+    graph.add_edge("validate", END)
+    return graph.compile()
+
+
+_questionnaire_graph = None
+
+
+def get_questionnaire_graph():
+    global _questionnaire_graph
+    if _questionnaire_graph is None:
+        _questionnaire_graph = build_questionnaire_graph()
+    return _questionnaire_graph

+ 15 - 0
cfc-langgraph/src/llm/client.py

@@ -0,0 +1,15 @@
+import os
+from langchain_openai import ChatOpenAI
+
+
+def get_llm() -> ChatOpenAI:
+    # 优先 LLM_* 系列(与 .env.production 一致),兼容 OPENAI_* 系列
+    # timeout=180/max_retries=1:问卷推理可达 30-60s,重试叠加会拖垮 gunicorn worker
+    return ChatOpenAI(
+        model=os.getenv("LLM_MODEL", os.getenv("OPENAI_MODEL", "gpt-4o-mini")),
+        api_key=os.getenv("LLM_API_KEY", os.getenv("OPENAI_API_KEY", "")),
+        base_url=os.getenv("LLM_BASE_URL", os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")),
+        temperature=0.7,
+        timeout=180,
+        max_retries=1,
+    )

+ 55 - 0
cfc-langgraph/src/prompts/questionnaire.py

@@ -0,0 +1,55 @@
+PARENT_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""
+
+CHILD_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""

+ 47 - 0
cfc-langgraph/src/schemas/questionnaire.py

@@ -0,0 +1,47 @@
+from pydantic import BaseModel, Field
+from typing import List, Optional, Union
+
+
+class Option(BaseModel):
+    id: str
+    score: float
+
+
+class Scale(BaseModel):
+    min: float = 0
+    max: float = 4
+
+
+class Question(BaseModel):
+    id: str
+    dimension: str = Field(pattern="^(trust|intimacy|communication)$")
+    direction: str = Field(default="positive", pattern="^(positive|negative)$")
+    weight: float = 1.0
+    text: str
+    options: Optional[List[Option]] = None
+    scale: Optional[Scale] = None
+
+
+class Questionnaire(BaseModel):
+    version: int = 1
+    questions: List[Question]
+
+    def validate_structure(self) -> bool:
+        """至少 1 题,每题有 options 或 scale"""
+        if not self.questions:
+            return False
+        for q in self.questions:
+            if q.options is None and q.scale is None:
+                return False
+        return True
+
+
+class GenerateRequest(BaseModel):
+    member_name: str
+    relationship_type: str = Field(pattern="^(parent|child)$")
+    family_context: Optional[dict] = None
+
+
+class GenerateResponse(BaseModel):
+    questionnaire_json: str
+    version: str