diff --git a/.github/agents/sage-examples.agent.md b/.github/agents/sage-examples.agent.md index e8978f6..5f27158 100644 --- a/.github/agents/sage-examples.agent.md +++ b/.github/agents/sage-examples.agent.md @@ -42,4 +42,4 @@ ______________________________________________________________________ - If cross-repo rollout is needed, keep changes here minimal and note any follow-up version bump separately. - Do not create `venv`/`.venv`; always reuse the existing configured Python environment. -``` \ No newline at end of file +``` diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ede28dd..4e33933 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,11 +28,19 @@ jobs: - name: Install dependencies run: | pip install --upgrade pip - pip install -e ".[dev]" - - - name: Install examples dependencies - run: | - pip install -e . + # This job only validates example entrypoint structure plus the direct third-party + # modules listed in examples/test_apps.py. Installing the full package pulls in the + # broader isage/isagellm graph, which causes Python 3.10 resolver backtracking. + pip install --prefer-binary \ + "pytest>=7.0.0" \ + "pyyaml>=6.0" \ + "requests>=2.25.0" \ + "pillow>=10.0.0" \ + "numpy>=1.26.0,<2.3.0" \ + "scikit-learn>=1.3.0" \ + "opencv-python-headless>=4.5.0" \ + "torch==2.7.0" \ + "torchvision==0.22.0" - name: Run example tests run: | diff --git a/.vscode/settings.json b/.vscode/settings.json index 191da9b..572cae7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,4 +8,4 @@ "packageManager": "ms-python.python:conda" } ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index 120c140..457be8c 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,20 @@ sage-examples/ Complete, runnable applications demonstrating real-world use cases: -| Application | Description | Script | -| ------------------------- | -------------------------- | ------------------------------------ | -| 🎬 **Video Intelligence** | Multi-model video analysis | `examples/run_video_intelligence.py` | -| 🏥 **Medical Diagnosis** | AI medical image analysis | `examples/run_medical_diagnosis.py` | -| 🏠 **Smart Home** | IoT automation system | `examples/run_smart_home.py` | -| 📰 **Article Monitoring** | News monitoring pipeline | `examples/run_article_monitoring.py` | -| 💬 **Auto-scaling Chat** | Dynamic scaling chat | `examples/run_auto_scaling_chat.py` | -| 📚 **Literature Recommendation** | Personalized scientific paper recommendation | `examples/run_literature_report_assistant.py` | -| 🧭 **Patent Landscape Mapper** | Patent clustering and whitespace opportunity mapping | `examples/run_patent_landscape_mapper.py` | -| 🎓 **Student Improvement** | Stateful learning diagnosis and score improvement MVP | `examples/run_student_improvement.py` | +| Application | Description | Script | +| -------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------- | +| 🎬 **Video Intelligence** | Multi-model video analysis | `examples/run_video_intelligence.py` | +| 🏥 **Medical Diagnosis** | AI medical image analysis | `examples/run_medical_diagnosis.py` | +| 🏠 **Smart Home** | IoT automation system | `examples/run_smart_home.py` | +| 📰 **Article Monitoring** | News monitoring pipeline | `examples/run_article_monitoring.py` | +| 💬 **Auto-scaling Chat** | Dynamic scaling chat | `examples/run_auto_scaling_chat.py` | +| 📚 **Literature Recommendation** | Personalized scientific paper recommendation | `examples/run_literature_report_assistant.py` | +| 🧭 **Patent Landscape Mapper** | Patent clustering and whitespace opportunity mapping | `examples/run_patent_landscape_mapper.py` | +| 🎓 **Student Improvement** | Stateful learning diagnosis and score improvement MVP | `examples/run_student_improvement.py` | +| 🚚 **Supply Chain Alert** | Stateful supply risk detection and dashboard snapshot MVP | `examples/run_supply_chain_alert.py` | +| 🌐 **Supply Chain Alert API** | FastAPI service for querying dashboard, alerts, and supplier risk | `examples/run_supply_chain_alert_api.py` | +| 🎫 **Ticket Triage** | Stateful customer support ticket classification, urgency scoring, and routing | `examples/run_ticket_triage.py` | +| 🌐 **Ticket Triage API** | FastAPI service for triage ingestion, queue queries, and ticket lookup | `examples/run_ticket_triage_api.py` | See `examples/README.md` for details. diff --git a/apps/README.md b/apps/README.md index 4be3e0c..9546cb7 100644 --- a/apps/README.md +++ b/apps/README.md @@ -130,7 +130,8 @@ sage-apps/ ### Core Framework - `isage>=0.3.0` - Consolidated SAGE core (`sage.foundation`, `sage.stream`, `sage.runtime`) -- Optional adapters such as `isagellm` / `isage-rag` when a specific app needs external inference or retrieval backends +- Optional adapters such as `isagellm` / `isage-rag` when a specific app needs external inference or + retrieval backends ### Application-Specific diff --git a/apps/pyproject.toml b/apps/pyproject.toml index 604f54b..00611c7 100644 --- a/apps/pyproject.toml +++ b/apps/pyproject.toml @@ -65,6 +65,8 @@ dependencies = [ "requests>=2.25.0", # HTTP 请求 "httpx>=0.28.0", # OpenAI-compatible client transport "openai>=1.52.0", # OpenAI-compatible API client + "fastapi>=0.115.0,<1.0.0", # API app factory and service endpoints + "uvicorn>=0.32.0,<1.0.0", # Local ASGI server for API demos "python-dotenv>=1.0.1", # 从 .env 加载本地模型配置 "redis>=5.0.0,<6.0.0", # Redis 缓存 "paho-mqtt>=1.6.0", # MQTT 协议 diff --git a/apps/src/sage/apps/__init__.py b/apps/src/sage/apps/__init__.py index bb236e9..176fdf9 100644 --- a/apps/src/sage/apps/__init__.py +++ b/apps/src/sage/apps/__init__.py @@ -7,6 +7,7 @@ - medical_diagnosis: AI-assisted medical imaging diagnosis - patent_landscape_mapper: Patent clustering, whitespace analysis, and assignee mapping - student_improvement: Personalized score improvement with stateful learning workflows +- ticket_triage: Customer support ticket triage with explicit stateful routing - work_report_generator: Weekly/daily work report generator with GitHub integration - literature_report_assistant: Literature search and reading report generation assistant diff --git a/apps/src/sage/apps/medical_diagnosis/agents/image_analyzer.py b/apps/src/sage/apps/medical_diagnosis/agents/image_analyzer.py index 056cabc..410eede 100644 --- a/apps/src/sage/apps/medical_diagnosis/agents/image_analyzer.py +++ b/apps/src/sage/apps/medical_diagnosis/agents/image_analyzer.py @@ -834,7 +834,7 @@ def _select_disc_masks( if bbox is None: continue - x, y, w, h = [int(value) for value in bbox] + x, y, w, h = (int(value) for value in bbox) if w <= 0 or h <= 0: continue diff --git a/apps/src/sage/apps/medical_diagnosis/run_diagnosis.py b/apps/src/sage/apps/medical_diagnosis/run_diagnosis.py index 474ca72..1896b67 100644 --- a/apps/src/sage/apps/medical_diagnosis/run_diagnosis.py +++ b/apps/src/sage/apps/medical_diagnosis/run_diagnosis.py @@ -15,7 +15,6 @@ from sage.apps.medical_diagnosis.agents import DiagnosticAgent - DEFAULT_CONFIG_PATH = Path(__file__).parent / "config" / "agent_config.yaml" diff --git a/apps/src/sage/apps/medical_diagnosis/setup_data.sh b/apps/src/sage/apps/medical_diagnosis/setup_data.sh index 21de724..79bf9ed 100755 --- a/apps/src/sage/apps/medical_diagnosis/setup_data.sh +++ b/apps/src/sage/apps/medical_diagnosis/setup_data.sh @@ -4,7 +4,6 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" DATA_DIR="$SCRIPT_DIR/data/processed" PYTHON_BIN="${PYTHON_BIN:-python}" diff --git a/apps/src/sage/apps/patent_landscape_mapper/README.md b/apps/src/sage/apps/patent_landscape_mapper/README.md index c405fdd..b3af4c0 100644 --- a/apps/src/sage/apps/patent_landscape_mapper/README.md +++ b/apps/src/sage/apps/patent_landscape_mapper/README.md @@ -55,4 +55,4 @@ The report includes: - replace demo data with a CSV or API-backed patent source - add CPC/IPC code normalization - enrich assignee rollups with corporate-family resolution -- compare two corpora for competitor benchmarking \ No newline at end of file +- compare two corpora for competitor benchmarking diff --git a/apps/src/sage/apps/patent_landscape_mapper/__init__.py b/apps/src/sage/apps/patent_landscape_mapper/__init__.py index c77c4b5..fa467fa 100644 --- a/apps/src/sage/apps/patent_landscape_mapper/__init__.py +++ b/apps/src/sage/apps/patent_landscape_mapper/__init__.py @@ -12,4 +12,4 @@ "build_demo_patent_corpus", "print_patent_landscape_report", "run_patent_landscape_mapper_pipeline", -] \ No newline at end of file +] diff --git a/apps/src/sage/apps/patent_landscape_mapper/analysis.py b/apps/src/sage/apps/patent_landscape_mapper/analysis.py index 7ff3bf0..47118c0 100644 --- a/apps/src/sage/apps/patent_landscape_mapper/analysis.py +++ b/apps/src/sage/apps/patent_landscape_mapper/analysis.py @@ -116,8 +116,12 @@ def _build_clusters( local_matrix = matrix[patent_indexes] similarities = cosine_similarity(local_matrix, centroid.reshape(1, -1)).reshape(-1) - representative_local_indexes = np.argsort(similarities)[::-1][: request.top_patents_per_cluster] - representative_indexes = [patent_indexes[index] for index in representative_local_indexes.tolist()] + representative_local_indexes = np.argsort(similarities)[::-1][ + : request.top_patents_per_cluster + ] + representative_indexes = [ + patent_indexes[index] for index in representative_local_indexes.tolist() + ] assignee_breakdown = dict(Counter(record.assignee for record in cluster_patents)) top_assignee_share = max(assignee_breakdown.values()) / len(cluster_patents) @@ -147,7 +151,9 @@ def _build_clusters( return sorted(states, key=lambda item: item.whitespace_score, reverse=True) -def _build_theme_clusters(states: list[_ClusterState], patents: list[PatentRecord]) -> list[ThemeCluster]: +def _build_theme_clusters( + states: list[_ClusterState], patents: list[PatentRecord] +) -> list[ThemeCluster]: return [ ThemeCluster( cluster_id=state.cluster_id, @@ -155,7 +161,9 @@ def _build_theme_clusters(states: list[_ClusterState], patents: list[PatentRecor top_terms=state.top_terms, patent_ids=[patents[index].patent_id for index in state.patent_indexes], patent_count=len(state.patent_indexes), - representative_patent_ids=[patents[index].patent_id for index in state.representative_indexes], + representative_patent_ids=[ + patents[index].patent_id for index in state.representative_indexes + ], assignee_breakdown=state.assignee_breakdown, average_focus_relevance=state.average_focus_relevance, whitespace_score=state.whitespace_score, @@ -187,7 +195,9 @@ def _build_whitespace_opportunities( rationale=rationale, target_keywords=state.top_terms[:3], opportunity_score=state.whitespace_score, - supporting_patent_ids=[patents[index].patent_id for index in state.representative_indexes], + supporting_patent_ids=[ + patents[index].patent_id for index in state.representative_indexes + ], ) ) return opportunities @@ -208,7 +218,12 @@ def _build_watchlist( for index in state.patent_indexes: record = patents[index] recency_score = (record.year - min_year) / year_span - watch_score = round(0.55 * float(focus_scores[index]) + 0.30 * state.whitespace_score + 0.15 * recency_score, 3) + watch_score = round( + 0.55 * float(focus_scores[index]) + + 0.30 * state.whitespace_score + + 0.15 * recency_score, + 3, + ) watch_items.append( PatentWatchItem( patent_id=record.patent_id, @@ -226,7 +241,9 @@ def _build_watchlist( return sorted(watch_items, key=lambda item: item.watch_score, reverse=True)[:5] -def _build_graph(states: list[_ClusterState], patents: list[PatentRecord]) -> tuple[list[LandscapeGraphNode], list[LandscapeGraphEdge]]: +def _build_graph( + states: list[_ClusterState], patents: list[PatentRecord] +) -> tuple[list[LandscapeGraphNode], list[LandscapeGraphEdge]]: nodes: list[LandscapeGraphNode] = [] edges: list[LandscapeGraphEdge] = [] seen_assignees: set[str] = set() @@ -312,8 +329,14 @@ def analyze_patent_landscape(request: PatentLandscapeRequest) -> PatentLandscape watchlist_patents = _build_watchlist(states, patents, focus_scores) graph_nodes, graph_edges = _build_graph(states, patents) - top_themes = ", ".join(cluster.label for cluster in theme_clusters[: min(ceil(cluster_count / 2), 3)]) - lead_opportunity = whitespace_opportunities[0].title if whitespace_opportunities else "No whitespace opportunity identified" + top_themes = ", ".join( + cluster.label for cluster in theme_clusters[: min(ceil(cluster_count / 2), 3)] + ) + lead_opportunity = ( + whitespace_opportunities[0].title + if whitespace_opportunities + else "No whitespace opportunity identified" + ) summary = ( f"Mapped {len(patents)} patents across {cluster_count} themes. Leading themes: {top_themes}. " f"Top whitespace opportunity: {lead_opportunity}." @@ -330,4 +353,4 @@ def analyze_patent_landscape(request: PatentLandscapeRequest) -> PatentLandscape watchlist_patents=watchlist_patents, graph_nodes=graph_nodes, graph_edges=graph_edges, - ) \ No newline at end of file + ) diff --git a/apps/src/sage/apps/patent_landscape_mapper/demo_data.py b/apps/src/sage/apps/patent_landscape_mapper/demo_data.py index 45650f6..5135145 100644 --- a/apps/src/sage/apps/patent_landscape_mapper/demo_data.py +++ b/apps/src/sage/apps/patent_landscape_mapper/demo_data.py @@ -160,4 +160,4 @@ def build_demo_patent_corpus() -> list[PatentRecord]: jurisdictions=["US", "EP"], tags=["cold chain", "edge inspection", "sterile storage"], ), - ] \ No newline at end of file + ] diff --git a/apps/src/sage/apps/patent_landscape_mapper/models.py b/apps/src/sage/apps/patent_landscape_mapper/models.py index 9e8d444..4088470 100644 --- a/apps/src/sage/apps/patent_landscape_mapper/models.py +++ b/apps/src/sage/apps/patent_landscape_mapper/models.py @@ -148,4 +148,4 @@ def to_dict(self) -> dict[str, Any]: "watchlist_patents": [item.to_dict() for item in self.watchlist_patents], "graph_nodes": [item.to_dict() for item in self.graph_nodes], "graph_edges": [item.to_dict() for item in self.graph_edges], - } \ No newline at end of file + } diff --git a/apps/src/sage/apps/patent_landscape_mapper/pipeline.py b/apps/src/sage/apps/patent_landscape_mapper/pipeline.py index 3c0b862..6b1ee1a 100644 --- a/apps/src/sage/apps/patent_landscape_mapper/pipeline.py +++ b/apps/src/sage/apps/patent_landscape_mapper/pipeline.py @@ -15,7 +15,11 @@ class ValidatePatentLandscapeRequest(MapFunction): def execute(self, data: PatentLandscapeRequest | dict[str, Any]) -> PatentLandscapeRequest: - request = data if isinstance(data, PatentLandscapeRequest) else PatentLandscapeRequest.from_dict(data) + request = ( + data + if isinstance(data, PatentLandscapeRequest) + else PatentLandscapeRequest.from_dict(data) + ) if len(request.patents) < 2: raise ValueError("Patent landscape mapper requires at least two patents.") if request.cluster_count < 2: @@ -170,4 +174,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/apps/src/sage/apps/student_improvement/README.md b/apps/src/sage/apps/student_improvement/README.md index a6ba970..890ab6d 100644 --- a/apps/src/sage/apps/student_improvement/README.md +++ b/apps/src/sage/apps/student_improvement/README.md @@ -82,4 +82,4 @@ export SAGE_OPENAI_MODEL="" - 将错因分析和解法生成替换为 LLM 增强版本 - 把本地状态仓替换为真正的 shared state / service backend -- 增加更丰富的题型解析、标签体系和长期学习轨迹建模 \ No newline at end of file +- 增加更丰富的题型解析、标签体系和长期学习轨迹建模 diff --git a/apps/src/sage/apps/student_improvement/app.py b/apps/src/sage/apps/student_improvement/app.py index ce9772d..92698b4 100644 --- a/apps/src/sage/apps/student_improvement/app.py +++ b/apps/src/sage/apps/student_improvement/app.py @@ -78,7 +78,9 @@ def _format_knowledge_names(knowledge_point_ids: list[str], graph_payload: dict[ return "、".join(names) -def _print_exam_snapshot(title: str, payload: dict[str, Any], graph_payload: dict[str, Any]) -> None: +def _print_exam_snapshot( + title: str, payload: dict[str, Any], graph_payload: dict[str, Any] +) -> None: _print_section(title) print( f"成绩: {payload['total_score']}/{payload['max_score']} " @@ -87,7 +89,9 @@ def _print_exam_snapshot(title: str, payload: dict[str, Any], graph_payload: dic print(f"新增错题数: {payload['wrong_question_count']}") print( "薄弱知识点: " - + "、".join(item["knowledge_point"] for item in payload["diagnosis"]["weak_knowledge_points"][:3]) + + "、".join( + item["knowledge_point"] for item in payload["diagnosis"]["weak_knowledge_points"][:3] + ) ) current_exam_time = payload["diagnosis"]["generated_at"] new_wrong_questions = [ @@ -144,7 +148,9 @@ def _print_mastery_changes( ) -def _print_wrong_bank(final_wrong_bank: list[dict[str, Any]], graph_payload: dict[str, Any]) -> None: +def _print_wrong_bank( + final_wrong_bank: list[dict[str, Any]], graph_payload: dict[str, Any] +) -> None: _print_section("动态错题集") for item in final_wrong_bank: knowledge_names = _format_knowledge_names(item["knowledge_point_ids"], graph_payload) @@ -225,7 +231,9 @@ def run_demo_once(storage_path: str | Path | None = None, *, json_mode: bool = F print("\n📘 SAGE Personalized Score Improvement MVP") print("展示流程: 初始化课程 -> 导入第一次考试 -> 导入第二次考试 -> 查看状态更新\n") - print(f"学生: {final_profile['student_id']} | {final_profile['grade']} {final_profile['subject']}") + print( + f"学生: {final_profile['student_id']} | {final_profile['grade']} {final_profile['subject']}" + ) print("课程: 八年级数学教材知识图谱 + 两次考试增量导入") print(f"诊断概览: {final_profile['diagnosis_overview']}") @@ -305,7 +313,9 @@ def _next_demo_exam(self): return exam return None - def import_next_demo_exam(self, *, render: bool = False) -> tuple[dict[str, Any], dict[str, Any]] | None: + def import_next_demo_exam( + self, *, render: bool = False + ) -> tuple[dict[str, Any], dict[str, Any]] | None: next_exam = self._next_demo_exam() if next_exam is None: print("所有 demo 考试都已经导入完毕。可选择查看总览,或重置 demo 状态。") @@ -324,7 +334,9 @@ def import_next_demo_exam(self, *, render: bool = False) -> tuple[dict[str, Any] def show_dashboard(self) -> None: try: - profile = _to_payload(_quiet_runtime_call(self.service.get_student_profile, self.student_id)) + profile = _to_payload( + _quiet_runtime_call(self.service.get_student_profile, self.student_id) + ) diagnosis = _to_payload( _quiet_runtime_call(self.service.get_student_diagnosis, self.student_id) ) @@ -372,7 +384,9 @@ def show_wrong_bank(self) -> None: _print_wrong_bank(wrong_bank, graph) def show_knowledge_graph(self) -> None: - graph = _to_payload(_quiet_runtime_call(self.service.get_student_knowledge_graph, self.student_id)) + graph = _to_payload( + _quiet_runtime_call(self.service.get_student_knowledge_graph, self.student_id) + ) _print_knowledge_graph(graph) def test_llm_connection(self) -> None: @@ -394,7 +408,9 @@ def test_llm_connection(self) -> None: def show_ai_guidance(self) -> None: try: - profile = _to_payload(_quiet_runtime_call(self.service.get_student_profile, self.student_id)) + profile = _to_payload( + _quiet_runtime_call(self.service.get_student_profile, self.student_id) + ) diagnosis = _to_payload( _quiet_runtime_call(self.service.get_student_diagnosis, self.student_id) ) diff --git a/apps/src/sage/apps/student_improvement/diagnosis.py b/apps/src/sage/apps/student_improvement/diagnosis.py index 3a13dd9..b4a6518 100644 --- a/apps/src/sage/apps/student_improvement/diagnosis.py +++ b/apps/src/sage/apps/student_improvement/diagnosis.py @@ -22,7 +22,9 @@ def _safe_float(value: str) -> float | None: return None -def classify_wrong_cause(question_prompt: str, student_answer: str, correct_answer: str) -> list[str]: +def classify_wrong_cause( + question_prompt: str, student_answer: str, correct_answer: str +) -> list[str]: answer = student_answer.strip() correct = correct_answer.strip() lowered_prompt = question_prompt.lower() @@ -40,7 +42,10 @@ def classify_wrong_cause(question_prompt: str, student_answer: str, correct_answ if "函数" in lowered_prompt and answer.lstrip("-").isdigit(): return ["概念错误"] - if any(keyword in lowered_prompt for keyword in ["解方程", "化简", "展开"]) and "=" not in answer: + if ( + any(keyword in lowered_prompt for keyword in ["解方程", "化简", "展开"]) + and "=" not in answer + ): return ["步骤缺失"] if len(answer) < max(1, len(correct) // 2): @@ -135,7 +140,9 @@ def merge_wrong_question_bank( last_seen_at=item.last_seen_at, occurrence_count=current.occurrence_count + 1, priority=round(current.priority + 0.6, 2), - related_exam_ids=list(dict.fromkeys(current.related_exam_ids + item.related_exam_ids)), + related_exam_ids=list( + dict.fromkeys(current.related_exam_ids + item.related_exam_ids) + ), mastery_status="unmastered", ) else: @@ -143,7 +150,9 @@ def merge_wrong_question_bank( latest_wrong_ids = {item.question_id for item in new_items} for question_id, item in list(merged.items()): - max_mastery = max((mastery_scores.get(kp_id, 0.5) for kp_id in item.knowledge_point_ids), default=0.5) + max_mastery = max( + (mastery_scores.get(kp_id, 0.5) for kp_id in item.knowledge_point_ids), default=0.5 + ) updated_priority = item.priority updated_status = item.mastery_status if question_id not in latest_wrong_ids: @@ -156,7 +165,9 @@ def merge_wrong_question_bank( updated_status = "unmastered" updated_priority = round(item.priority + 0.3, 2) - merged[question_id] = replace(item, mastery_status=updated_status, priority=updated_priority) + merged[question_id] = replace( + item, mastery_status=updated_status, priority=updated_priority + ) return sorted(merged.values(), key=lambda wrong_item: wrong_item.priority, reverse=True) @@ -169,7 +180,9 @@ def build_risk_ranking( latest_wrong_counter: dict[str, int] = {} for wrong_item in wrong_questions: for knowledge_point_id in wrong_item.knowledge_point_ids: - latest_wrong_counter[knowledge_point_id] = latest_wrong_counter.get(knowledge_point_id, 0) + 1 + latest_wrong_counter[knowledge_point_id] = ( + latest_wrong_counter.get(knowledge_point_id, 0) + 1 + ) rows: list[dict[str, object]] = [] for knowledge_point in knowledge_base.curriculum.knowledge_points: @@ -202,7 +215,10 @@ def build_study_plan( prerequisites: list[str] = [] for knowledge_point_id in selected_knowledge_point_ids: for prerequisite_id in knowledge_base.get_prerequisite_chain(knowledge_point_id): - if prerequisite_id not in prerequisites and prerequisite_id not in selected_knowledge_point_ids: + if ( + prerequisite_id not in prerequisites + and prerequisite_id not in selected_knowledge_point_ids + ): prerequisites.append(prerequisite_id) if prerequisites: @@ -309,12 +325,9 @@ def build_student_profile( wrong_questions: list[WrongQuestionItem], knowledge_base: CurriculumKnowledgeBase, ) -> StudentProfile: - top_weak_names = [ - str(item["knowledge_point"]) for item in diagnosis.weak_knowledge_points[:3] - ] - overview = ( - "当前最需要优先提升的知识点:" - + ("、".join(top_weak_names) if top_weak_names else "暂无明显薄弱点") + top_weak_names = [str(item["knowledge_point"]) for item in diagnosis.weak_knowledge_points[:3]] + overview = "当前最需要优先提升的知识点:" + ( + "、".join(top_weak_names) if top_weak_names else "暂无明显薄弱点" ) return StudentProfile( student_id=exam.student_id, diff --git a/apps/src/sage/apps/student_improvement/knowledge_base.py b/apps/src/sage/apps/student_improvement/knowledge_base.py index dc961e5..553e01f 100644 --- a/apps/src/sage/apps/student_improvement/knowledge_base.py +++ b/apps/src/sage/apps/student_improvement/knowledge_base.py @@ -25,7 +25,9 @@ def __init__(self, curriculum: Curriculum) -> None: self._knowledge_index = { knowledge.knowledge_point_id: knowledge for knowledge in curriculum.knowledge_points } - self._question_index = {question.question_id: question for question in curriculum.question_bank} + self._question_index = { + question.question_id: question for question in curriculum.question_bank + } self._question_map = { key: list(value) for key, value in curriculum.question_to_knowledge_map.items() } @@ -127,7 +129,9 @@ def enrich_question(self, question: ExamQuestionResult) -> ExamQuestionResult: template = self.get_question_template(question.question_id) return replace( question, - prompt=template.prompt if template is not None and not question.prompt else question.prompt, + prompt=template.prompt + if template is not None and not question.prompt + else question.prompt, correct_answer=( template.correct_answer if template is not None and not question.correct_answer diff --git a/apps/src/sage/apps/student_improvement/workflow.py b/apps/src/sage/apps/student_improvement/workflow.py index 9c5a6df..9c4341f 100644 --- a/apps/src/sage/apps/student_improvement/workflow.py +++ b/apps/src/sage/apps/student_improvement/workflow.py @@ -78,7 +78,9 @@ def execute(self, data: ExamRecord | dict[str, Any]) -> dict[str, Any]: exam = data if isinstance(data, ExamRecord) else ExamRecord.from_dict(data) curriculum = self.call_service(STATE_SERVICE_NAME, method="get_curriculum") if curriculum is None: - raise RuntimeError("Curriculum has not been initialized. Run initialize_curriculum first.") + raise RuntimeError( + "Curriculum has not been initialized. Run initialize_curriculum first." + ) knowledge_base = CurriculumKnowledgeBase(curriculum) enriched_exam = knowledge_base.enrich_exam_record(exam) @@ -178,7 +180,9 @@ def __init__(self, state_store: InMemoryStudentImprovementStateStore | None = No def _build_environment(self, name: str) -> LocalEnvironment: environment = LocalEnvironment(name) environment.set_console_log_level("ERROR") - environment.register_service(STATE_SERVICE_NAME, StudentImprovementStateService, self.state_store) + environment.register_service( + STATE_SERVICE_NAME, StudentImprovementStateService, self.state_store + ) return environment def initialize_curriculum(self, curriculum: Curriculum) -> CurriculumInitializationResult: diff --git a/apps/src/sage/apps/supply_chain_alert/README.md b/apps/src/sage/apps/supply_chain_alert/README.md new file mode 100644 index 0000000..caef987 --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/README.md @@ -0,0 +1,160 @@ +# Supply Chain Alert Dashboard MVP + +这是一个基于 SAGE 本地运行时与显式状态服务实现的“供应链异常预警看板”第一版。 + +## 目标 + +- 接收采购订单、库存、物流、供应商履约等事件 +- 用显式 SAGE 算子链检测可解释的规则异常 +- 用状态仓维护库存、开放订单、物流和供应商画像 +- 输出结构化预警列表与最终 dashboard 快照 + +## 核心设计 + +- models.py: 定义输入事件、标准化事件、异常信号、预警对象和 dashboard 快照 +- demo_data.py: 提供离线可跑的演示事件流 +- state_store.py: 维护库存、订单、物流、供应商和开放预警状态 +- operators.py: 用 BatchFunction, MapFunction, SinkFunction 组织主链路 +- workflow.py: 用 LocalEnvironment + BaseService 执行状态化工作流 +- service.py: 暴露应用服务门面,并提供可选 FastAPI app factory + +## 如何运行 + +在仓库根目录执行: + +```bash +python examples/run_supply_chain_alert.py +``` + +也可以输出 JSON: + +```bash +python examples/run_supply_chain_alert.py --json +python examples/run_supply_chain_alert.py --storage-path .sage/supply-chain-alert.json +``` + +启动 API 服务: + +```bash +python examples/run_supply_chain_alert_api.py +python examples/run_supply_chain_alert_api.py --host 0.0.0.0 --port 8010 +``` + +默认开放这些接口: + +- GET / +- GET /health +- POST /events/ingest +- GET /dashboard +- GET /dashboard/ui +- GET /alerts/open +- GET /alerts/explanations +- GET /suppliers/risk +- POST /demo/reset-and-run + +如果希望直接打开图形界面,可以在启动 API 后访问: + +```bash +http://127.0.0.1:8000/dashboard/ui +http://127.0.0.1:8010/dashboard/ui +``` + +这个页面内置三个动作: + +- 加载演示数据 +- 刷新看板 +- 生成中文解释 + +同时已经扩展成多角色 Web dashboard,页面里可以直接切换: + +- 运营总控 +- 采购协调 +- 仓配调度 +- 供应商管理 + +每个角色会看到不同的: + +- 角色摘要 +- 优先动作队列 +- 角色关注预警 +- 角色关注供应商 + +即使自然语言解释依赖的 gateway 暂时不可用,角色摘要和动作队列也会继续基于规则结果显示,不会整块空掉。 + +如果希望直接从 CLI 演示自然语言解释,可以执行: + +```bash +python examples/run_supply_chain_alert.py --explain +python examples/run_supply_chain_alert.py --json --explain +``` + +## sage.serving 网关增强 + +这一层是可选增强,不参与底层规则判定。主链路仍然是本地 SAGE runtime + 显式状态服务;只有在 `probe_gateway(...)` 探测到网关可用时,才会调用 +OpenAI-compatible 接口生成中文风险解释。 + +推荐通过环境变量配置: + +```bash +export SAGE_SUPPLY_CHAIN_GATEWAY_HOST="127.0.0.1" +export SAGE_SUPPLY_CHAIN_GATEWAY_PORT="19000" +export SAGE_SUPPLY_CHAIN_MODEL="" +export SAGE_SUPPLY_CHAIN_API_KEY="EMPTY" # pragma: allowlist secret +``` + +如果你们用的是远程 OpenAI-compatible gateway,也可以直接传完整 base URL: + +```bash +export SAGE_SUPPLY_CHAIN_BASE_URL="https://api.sage.org.ai/v1" +export SAGE_SUPPLY_CHAIN_HEALTH_URL="https://api.sage.org.ai/health" +export SAGE_SUPPLY_CHAIN_MODEL="" +export SAGE_SUPPLY_CHAIN_API_KEY="" # pragma: allowlist secret +``` + +如果要直接验证远程解释链路,推荐先用这两组命令: + +```bash +export SAGE_SUPPLY_CHAIN_BASE_URL="https://api.sage.org.ai/v1" +export SAGE_SUPPLY_CHAIN_HEALTH_URL="https://api.sage.org.ai/health" +export SAGE_SUPPLY_CHAIN_MODEL="Qwen/Qwen2.5-7B-Instruct" +export SAGE_SUPPLY_CHAIN_API_KEY="" # pragma: allowlist secret + +python examples/run_supply_chain_alert.py --json --explain +``` + +如果要通过 HTTP API 查看解释结果,可以在同一组环境变量下启动服务: + +```bash +python examples/run_supply_chain_alert_api.py --host 127.0.0.1 --port 8010 +``` + +然后依次访问: + +```bash +curl -X POST http://127.0.0.1:8010/demo/reset-and-run +curl "http://127.0.0.1:8010/alerts/explanations?max_alerts=1" +``` + +浏览器里直接打开 `http://127.0.0.1:8010/alerts/explanations?max_alerts=1` 也可以看到 JSON 形式的中文风险解释。 如果希望查看图形版 +dashboard,也可以直接打开 `http://127.0.0.1:8010/dashboard/ui`。 + +说明: + +- `SAGE_SUPPLY_CHAIN_MODEL` 可选;如果不设,会先从网关列模型再选择第一个 +- `SAGE_SUPPLY_CHAIN_API_KEY` 默认会回退到 `SAGE_OPENAI_API_KEY` / `OPENAI_API_KEY`,本地网关一般可用 `EMPTY` +- `SAGE_SUPPLY_CHAIN_BASE_URL` 适合远程 HTTPS 网关;设置后会优先于本地 host/port 配置 +- 网关不可达时,应用不会影响原有规则预警,只会明确返回“解释不可用”的状态和错误信息 + +## 当前 MVP 边界 + +- 主链路规则版异常检测不依赖外部大模型;自然语言解释为可选 gateway 增强 +- 主链路固定在 LocalEnvironment,不启用 FlowNetEnvironment +- 状态存储默认为内存,可选写入本地 JSON 文件 +- 提供应用服务门面、FastAPI app factory 和可选的自然语言解释入口 + +## 下一步可以扩展什么 + +- 接入真实订单/库存/物流数据源 +- 用共享状态服务替换本地 JSON 状态仓 +- 继续增强多角色 Web dashboard,例如自动轮询、角色级 SLA、按仓库/供应商过滤 +- 补一个终端交互式 CLI 控制台,适合纯 SSH 环境排障和值班 diff --git a/apps/src/sage/apps/supply_chain_alert/__init__.py b/apps/src/sage/apps/supply_chain_alert/__init__.py new file mode 100644 index 0000000..fe92679 --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/__init__.py @@ -0,0 +1,43 @@ +"""Supply chain alert dashboard MVP built on top of SAGE stateful workflows.""" + +from .demo_data import build_demo_event_summary, build_demo_supply_events +from .llm import SupplyChainGatewaySettings, SupplyChainRiskExplainer +from .models import ( + AlertExplanation, + DashboardSnapshot, + GatewayExplanationStatus, + InventoryEvent, + OpenAlert, + PurchaseOrderEvent, + RiskExplanationResult, + ShipmentEvent, + SupplierPerformanceEvent, + SupplierRiskSummary, + SupplyChainRunResult, +) +from .service import ( + SupplyChainAlertApplicationService, + create_demo_application_service, + create_fastapi_app, +) + +__all__ = [ + "AlertExplanation", + "DashboardSnapshot", + "GatewayExplanationStatus", + "InventoryEvent", + "OpenAlert", + "PurchaseOrderEvent", + "RiskExplanationResult", + "ShipmentEvent", + "SupplyChainGatewaySettings", + "SupplierPerformanceEvent", + "SupplyChainRiskExplainer", + "SupplierRiskSummary", + "SupplyChainAlertApplicationService", + "SupplyChainRunResult", + "build_demo_event_summary", + "build_demo_supply_events", + "create_demo_application_service", + "create_fastapi_app", +] diff --git a/apps/src/sage/apps/supply_chain_alert/demo_data.py b/apps/src/sage/apps/supply_chain_alert/demo_data.py new file mode 100644 index 0000000..dabce45 --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/demo_data.py @@ -0,0 +1,143 @@ +"""Demo data for the supply chain alert dashboard MVP.""" + +from __future__ import annotations + +from .models import ( + InventoryEvent, + PurchaseOrderEvent, + ShipmentEvent, + SupplierPerformanceEvent, + SupplyEvent, +) + + +def build_demo_supply_events() -> list[SupplyEvent]: + return [ + InventoryEvent( + event_id="inventory:wh-sh:chip-a:2026-01-15T09:00:00", + sku="CHIP-A", + warehouse="WH-SH", + current_stock=18, + safety_stock=40, + updated_at="2026-01-15T09:00:00", + ), + InventoryEvent( + event_id="inventory:wh-bj:chip-a:2026-01-15T09:03:00", + sku="CHIP-A", + warehouse="WH-BJ", + current_stock=120, + safety_stock=30, + updated_at="2026-01-15T09:03:00", + ), + SupplierPerformanceEvent( + event_id="supplier:alpha:2026-01-15T09:05:00", + supplier_id="SUP-ALPHA", + supplier_name="Alpha Components", + on_time_rate=0.72, + defect_rate=0.035, + breach_count_30d=5, + observed_at="2026-01-15T09:05:00", + ), + SupplierPerformanceEvent( + event_id="supplier:beta:2026-01-15T09:06:00", + supplier_id="SUP-BETA", + supplier_name="Beta Logistics Parts", + on_time_rate=0.91, + defect_rate=0.018, + breach_count_30d=1, + observed_at="2026-01-15T09:06:00", + ), + SupplierPerformanceEvent( + event_id="supplier:gamma:2026-01-15T09:07:00", + supplier_id="SUP-GAMMA", + supplier_name="Gamma Electronics", + on_time_rate=0.97, + defect_rate=0.008, + breach_count_30d=0, + observed_at="2026-01-15T09:07:00", + ), + PurchaseOrderEvent( + event_id="po:1001:2026-01-15T21:30:00", + order_id="PO-1001", + sku="CHIP-A", + quantity=60, + warehouse="WH-SH", + supplier_id="SUP-ALPHA", + created_at="2026-01-14T08:30:00", + promised_delivery="2026-01-15T18:00:00", + observed_at="2026-01-15T21:30:00", + status="pending", + priority="high", + ), + PurchaseOrderEvent( + event_id="po:1002:2026-01-15T21:31:00", + order_id="PO-1002", + sku="CHIP-A", + quantity=40, + warehouse="WH-SH", + supplier_id="SUP-ALPHA", + created_at="2026-01-14T09:00:00", + promised_delivery="2026-01-15T20:00:00", + observed_at="2026-01-15T21:31:00", + status="pending", + priority="high", + ), + PurchaseOrderEvent( + event_id="po:1003:2026-01-15T13:00:00", + order_id="PO-1003", + sku="CHIP-A", + quantity=10, + warehouse="WH-SH", + supplier_id="SUP-GAMMA", + created_at="2026-01-15T13:00:00", + promised_delivery="2026-01-16T12:00:00", + observed_at="2026-01-15T13:00:00", + status="confirmed", + priority="standard", + ), + PurchaseOrderEvent( + event_id="po:2001:2026-01-15T10:15:00", + order_id="PO-2001", + sku="SENSOR-B", + quantity=25, + warehouse="WH-SH", + supplier_id="SUP-BETA", + created_at="2026-01-15T10:15:00", + promised_delivery="2026-01-15T22:00:00", + observed_at="2026-01-15T10:15:00", + status="shipped", + priority="standard", + ), + ShipmentEvent( + event_id="shipment:9001:2026-01-15T10:45:00", + shipment_id="SHIP-9001", + order_id="PO-2001", + sku="SENSOR-B", + warehouse="WH-SH", + supplier_id="SUP-BETA", + current_location="Nanjing Transfer Hub", + status="in_transit", + promised_delivery="2026-01-15T22:00:00", + estimated_arrival="2026-01-16T10:00:00", + recorded_at="2026-01-15T10:45:00", + delay_hours=12.0, + status_age_hours=9.5, + ), + ] + + +def build_demo_event_summary() -> dict[str, int]: + events = build_demo_supply_events() + warehouses = { + event.warehouse for event in events if hasattr(event, "warehouse") and event.warehouse + } + suppliers = { + event.supplier_id for event in events if hasattr(event, "supplier_id") and event.supplier_id + } + orders = {event.order_id for event in events if isinstance(event, PurchaseOrderEvent)} + return { + "warehouse_count": len(warehouses), + "supplier_count": len(suppliers), + "order_count": len(orders), + "event_count": len(events), + } diff --git a/apps/src/sage/apps/supply_chain_alert/llm.py b/apps/src/sage/apps/supply_chain_alert/llm.py new file mode 100644 index 0000000..74d4406 --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/llm.py @@ -0,0 +1,490 @@ +"""Optional SAGE serving gateway integration for natural-language risk explanations.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from sage.foundation import SagePorts +from sage.serving import SageServeConfig, probe_gateway + +from .models import ( + AlertExplanation, + DashboardSnapshot, + GatewayExplanationStatus, + OpenAlert, + RiskExplanationResult, + SupplierRiskSummary, +) + +try: + import httpx + from openai import OpenAI +except ImportError: # pragma: no cover - dependency availability is environment-specific + httpx = None + OpenAI = None + +try: + from dotenv import load_dotenv +except ImportError: # pragma: no cover - dependency availability is environment-specific + load_dotenv = None + + +DEFAULT_USER_AGENT = "sage-supply-chain-alert/0.1.0" + + +def _load_local_env_file(env_file: str | os.PathLike[str] | None = None) -> None: + if load_dotenv is None: + return + + candidate_paths: list[Path] = [] + if env_file is not None: + candidate_paths.append(Path(env_file).expanduser()) + else: + candidate_paths.append(Path.cwd() / ".env") + for parent in Path(__file__).resolve().parents: + candidate_paths.append(parent / ".env") + + seen_paths: set[Path] = set() + for candidate_path in candidate_paths: + resolved_path = candidate_path.resolve() + if resolved_path in seen_paths: + continue + seen_paths.add(resolved_path) + if resolved_path.is_file(): + load_dotenv(dotenv_path=resolved_path, override=False) + return + + +@dataclass(frozen=True) +class SupplyChainGatewaySettings: + base_url_override: str | None = None + health_url_override: str | None = None + host: str = "127.0.0.1" + port: int = SagePorts.SAGELLM_GATEWAY + model: str | None = None + api_key: str = "EMPTY" + health_timeout: float = 3.0 + request_timeout: float = 30.0 + + @classmethod + def from_env( + cls, + env_file: str | os.PathLike[str] | None = None, + ) -> SupplyChainGatewaySettings: + _load_local_env_file(env_file) + port_value = os.getenv("SAGE_SUPPLY_CHAIN_GATEWAY_PORT") + base_url = os.getenv("SAGE_SUPPLY_CHAIN_BASE_URL") or os.getenv("SAGE_OPENAI_BASE_URL") + api_key = ( + os.getenv("SAGE_SUPPLY_CHAIN_API_KEY") + or os.getenv("SAGE_OPENAI_API_KEY") + or os.getenv("OPENAI_API_KEY") + or "EMPTY" + ) + model = os.getenv("SAGE_SUPPLY_CHAIN_MODEL") or os.getenv("SAGE_OPENAI_MODEL") + return cls( + base_url_override=base_url.rstrip("/") if base_url else None, + health_url_override=os.getenv("SAGE_SUPPLY_CHAIN_HEALTH_URL"), + host=os.getenv("SAGE_SUPPLY_CHAIN_GATEWAY_HOST", "127.0.0.1"), + port=int(port_value) if port_value else SagePorts.SAGELLM_GATEWAY, + model=model, + api_key=api_key, + health_timeout=float(os.getenv("SAGE_SUPPLY_CHAIN_HEALTH_TIMEOUT", "3.0")), + request_timeout=float(os.getenv("SAGE_SUPPLY_CHAIN_REQUEST_TIMEOUT", "30.0")), + ) + + @property + def gateway_config(self) -> SageServeConfig: + return SageServeConfig(host=self.host, port=self.port, model=self.model) + + @property + def base_url(self) -> str: + if self.base_url_override is not None: + return self.base_url_override + return self.gateway_config.base_url + + @property + def health_url(self) -> str: + if self.health_url_override is not None: + return self.health_url_override + if self.base_url_override is not None: + parsed = urlparse(self.base_url_override) + return f"{parsed.scheme}://{parsed.netloc}/health" + return self.gateway_config.health_url + + +class SupplyChainRiskExplainer: + """Generate natural-language supply-chain risk explanations via SAGE serving.""" + + def __init__(self, settings: SupplyChainGatewaySettings | None = None) -> None: + self.settings = settings or SupplyChainGatewaySettings.from_env() + + def explain_current_risks( + self, + *, + dashboard: DashboardSnapshot, + alerts: list[OpenAlert], + supplier_risk_summaries: list[SupplierRiskSummary], + max_alerts: int = 5, + ) -> RiskExplanationResult: + probe = self._probe_gateway() + if not probe.ok and self.settings.base_url_override is None: + gateway_status = GatewayExplanationStatus( + reachable=False, + base_url=self.settings.base_url, + health_url=self.settings.health_url, + model=self.settings.model, + status_code=probe.status_code, + error=probe.error, + ) + return RiskExplanationResult( + generated_at=dashboard.generated_at, + gateway_status=gateway_status, + ) + + try: + client = self._build_client() + model_name = self._resolve_model(client) + dashboard_summary = self.generate_dashboard_summary( + client=client, + model_name=model_name, + dashboard=dashboard, + supplier_risk_summaries=supplier_risk_summaries, + ) + alert_explanations = [ + AlertExplanation( + alert_id=alert.alert_id, + rule_id=alert.rule_id, + title=alert.title, + risk_level=alert.risk_level, + explanation=self.generate_alert_explanation( + client=client, + model_name=model_name, + alert=alert, + dashboard=dashboard, + ), + ) + for alert in alerts[:max_alerts] + ] + return RiskExplanationResult( + generated_at=dashboard.generated_at, + gateway_status=GatewayExplanationStatus( + reachable=True, + base_url=self.settings.base_url, + health_url=self.settings.health_url, + model=model_name, + status_code=probe.status_code if probe.ok else None, + error=None, + ), + dashboard_summary=dashboard_summary, + alert_explanations=alert_explanations, + ) + except RuntimeError as exc: + if not probe.ok: + error_message = str(exc) + return RiskExplanationResult( + generated_at=dashboard.generated_at, + gateway_status=GatewayExplanationStatus( + reachable=False, + base_url=self.settings.base_url, + health_url=self.settings.health_url, + model=self.settings.model, + status_code=probe.status_code, + error=error_message, + ), + ) + return RiskExplanationResult( + generated_at=dashboard.generated_at, + gateway_status=GatewayExplanationStatus( + reachable=True, + base_url=self.settings.base_url, + health_url=self.settings.health_url, + model=self.settings.model, + status_code=probe.status_code, + error=str(exc), + ), + ) + + def _probe_gateway(self): + if self.settings.base_url_override is None: + return probe_gateway(self.settings.gateway_config, timeout=self.settings.health_timeout) + + try: + if httpx is None: + raise RuntimeError("Missing httpx dependency for remote gateway probing.") + with httpx.Client(timeout=self.settings.health_timeout) as client: + response = client.get(self.settings.health_url) + payload = None + try: + payload = response.json() + except ValueError: + payload = None + return type( + "RemoteProbeResult", + (), + { + "ok": 200 <= response.status_code < 300, + "url": self.settings.health_url, + "status_code": response.status_code, + "payload": payload, + "error": None if 200 <= response.status_code < 300 else response.text, + }, + )() + except Exception as exc: # noqa: BLE001 + return type( + "RemoteProbeResult", + (), + { + "ok": False, + "url": self.settings.health_url, + "status_code": None, + "payload": None, + "error": str(exc), + }, + )() + + def _build_client(self): + if httpx is None: + raise RuntimeError( + "Missing gateway client dependencies. Ensure httpx is installed.", + ) + if self.settings.base_url_override is not None: + return httpx.Client( + headers={ + "User-Agent": DEFAULT_USER_AGENT, + "Authorization": f"Bearer {self.settings.api_key}", + "Content-Type": "application/json", + }, + timeout=self.settings.request_timeout, + follow_redirects=True, + ) + if OpenAI is None: + raise RuntimeError( + "Missing OpenAI SDK dependency for local gateway mode. Ensure openai is installed.", + ) + http_client = httpx.Client( + headers={"User-Agent": DEFAULT_USER_AGENT}, + timeout=self.settings.request_timeout, + ) + return OpenAI( + base_url=self.settings.base_url, + api_key=self.settings.api_key, + http_client=http_client, + ) + + def _resolve_model(self, client) -> str: + if self.settings.model: + return self.settings.model + if self.settings.base_url_override is not None: + try: + response = client.get(f"{self.settings.base_url}/models") + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"Failed to list remote gateway models: {exc}") from exc + payload = response.json() + model_ids = [ + item.get("id") + for item in payload.get("data", []) + if isinstance(item, dict) and item.get("id") + ] + if not model_ids: + raise RuntimeError("The configured SAGE gateway returned no models.") + return model_ids[0] + response = client.models.list() + model_ids = [item.id for item in getattr(response, "data", [])] + if not model_ids: + raise RuntimeError("The configured SAGE gateway returned no models.") + return model_ids[0] + + def generate_dashboard_summary( + self, + *, + client, + model_name: str, + dashboard: DashboardSnapshot, + supplier_risk_summaries: list[SupplierRiskSummary], + ) -> str: + payload = self._build_dashboard_prompt_payload(dashboard, supplier_risk_summaries) + return self._complete_text( + client=client, + model_name=model_name, + system_prompt=( + "你是供应链风险运营助手。请基于结构化输入,输出一段简洁中文管理摘要。" + "要求:1) 先说当前总风险 2) 点出最需要关注的两个问题 3) 最后给出一条总动作建议。" + "不要编造输入中不存在的数据。" + ), + user_payload=payload, + ) + + def generate_alert_explanation( + self, + *, + client, + model_name: str, + alert: OpenAlert, + dashboard: DashboardSnapshot, + ) -> str: + payload = self._build_alert_prompt_payload(alert, dashboard) + return self._complete_text( + client=client, + model_name=model_name, + system_prompt=( + "你是供应链异常解释助手。请针对单条预警输出简洁、可执行、可解释的中文说明。" + "格式要求:先解释为什么触发,再说明可能业务影响,最后说明建议动作。" + "不要使用项目符号,不要虚构额外事实。" + ), + user_payload=payload, + ) + + def _complete_text( + self, + *, + client, + model_name: str, + system_prompt: str, + user_payload: dict[str, object], + ) -> str: + if self.settings.base_url_override is not None: + return self._complete_text_httpx( + client=client, + model_name=model_name, + system_prompt=system_prompt, + user_payload=user_payload, + ) + response = client.chat.completions.create( + model=model_name, + temperature=0.2, + messages=[ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": json.dumps(user_payload, ensure_ascii=False, indent=2), + }, + ], + ) + choices = getattr(response, "choices", None) or [] + if not choices: + raise RuntimeError("SAGE gateway returned no completion choices.") + + message = getattr(choices[0], "message", None) + content = getattr(message, "content", None) + if isinstance(content, str) and content.strip(): + return content.strip() + + if isinstance(content, list): + text_chunks = [ + item.get("text", "") + for item in content + if isinstance(item, dict) and item.get("type") in {"text", "output_text"} + ] + merged = "\n".join(chunk for chunk in text_chunks if chunk).strip() + if merged: + return merged + + raise RuntimeError("SAGE gateway returned an empty explanation response.") + + def _complete_text_httpx( + self, + *, + client, + model_name: str, + system_prompt: str, + user_payload: dict[str, object], + ) -> str: + request_payload = { + "model": model_name, + "temperature": 0.2, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": self._render_user_payload(user_payload)}, + ], + } + try: + response = client.post( + f"{self.settings.base_url}/chat/completions", + json=request_payload, + ) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"Remote gateway completion failed: {exc}") from exc + + payload = response.json() + choices = payload.get("choices", []) + if not choices: + raise RuntimeError("SAGE gateway returned no completion choices.") + + message = choices[0].get("message", {}) + content = message.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + + if isinstance(content, list): + text_chunks = [ + item.get("text", "") + for item in content + if isinstance(item, dict) and item.get("type") in {"text", "output_text"} + ] + merged = "\n".join(chunk for chunk in text_chunks if chunk).strip() + if merged: + return merged + + raise RuntimeError("SAGE gateway returned an empty explanation response.") + + def _build_dashboard_prompt_payload( + self, + dashboard: DashboardSnapshot, + supplier_risk_summaries: list[SupplierRiskSummary], + ) -> dict[str, Any]: + return { + "generated_at": dashboard.generated_at, + "open_alert_count": dashboard.open_alert_count, + "high_risk_alert_count": dashboard.high_risk_alert_count, + "low_stock_sku_count": dashboard.low_stock_sku_count, + "delayed_shipment_count": dashboard.delayed_shipment_count, + "overdue_order_count": dashboard.overdue_order_count, + "high_risk_supplier_count": dashboard.high_risk_supplier_count, + "average_delay_hours": dashboard.average_delay_hours, + "top_shortage_skus": dashboard.top_shortage_skus[:3], + "top_risk_suppliers": [ + { + "supplier_id": item.supplier_id, + "risk_score": item.risk_score, + "open_alert_count": item.open_alert_count, + } + for item in supplier_risk_summaries[:3] + ], + } + + def _build_alert_prompt_payload( + self, + alert: OpenAlert, + dashboard: DashboardSnapshot, + ) -> dict[str, Any]: + return { + "alert": { + "alert_id": alert.alert_id, + "rule_id": alert.rule_id, + "title": alert.title, + "risk_level": alert.risk_level, + "summary": alert.summary, + "sku": alert.sku, + "warehouse": alert.warehouse, + "supplier_id": alert.supplier_id, + "recommended_actions": alert.recommended_actions[:3], + "alternative_suppliers": alert.alternative_suppliers[:2], + }, + "dashboard_context": { + "open_alert_count": dashboard.open_alert_count, + "high_risk_alert_count": dashboard.high_risk_alert_count, + "low_stock_sku_count": dashboard.low_stock_sku_count, + "delayed_shipment_count": dashboard.delayed_shipment_count, + "overdue_order_count": dashboard.overdue_order_count, + }, + } + + def _render_user_payload(self, user_payload: dict[str, object]) -> str: + return json.dumps(user_payload, ensure_ascii=False, separators=(",", ":")) diff --git a/apps/src/sage/apps/supply_chain_alert/models.py b/apps/src/sage/apps/supply_chain_alert/models.py new file mode 100644 index 0000000..262ab72 --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/models.py @@ -0,0 +1,432 @@ +"""Structured models for the supply chain alert dashboard MVP.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, TypeAlias + + +@dataclass(frozen=True) +class PurchaseOrderEvent: + event_id: str + order_id: str + sku: str + quantity: int + warehouse: str + supplier_id: str + created_at: str + promised_delivery: str + observed_at: str + status: str = "pending" + priority: str = "standard" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PurchaseOrderEvent: + created_at = str(data.get("created_at", data.get("observed_at", ""))) + observed_at = str(data.get("observed_at", created_at)) + return cls( + event_id=str(data.get("event_id", f"po:{data['order_id']}:{observed_at}")), + order_id=str(data["order_id"]), + sku=str(data["sku"]), + quantity=int(data["quantity"]), + warehouse=str(data["warehouse"]), + supplier_id=str(data["supplier_id"]), + created_at=created_at, + promised_delivery=str(data["promised_delivery"]), + observed_at=observed_at, + status=str(data.get("status", "pending")), + priority=str(data.get("priority", "standard")), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class InventoryEvent: + event_id: str + sku: str + warehouse: str + current_stock: int + safety_stock: int + updated_at: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> InventoryEvent: + updated_at = str(data["updated_at"]) + return cls( + event_id=str( + data.get("event_id", f"inventory:{data['warehouse']}:{data['sku']}:{updated_at}") + ), + sku=str(data["sku"]), + warehouse=str(data["warehouse"]), + current_stock=int(data["current_stock"]), + safety_stock=int(data["safety_stock"]), + updated_at=updated_at, + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class ShipmentEvent: + event_id: str + shipment_id: str + order_id: str + sku: str + warehouse: str + supplier_id: str + current_location: str + status: str + promised_delivery: str + estimated_arrival: str + recorded_at: str + delay_hours: float = 0.0 + status_age_hours: float = 0.0 + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ShipmentEvent: + recorded_at = str(data["recorded_at"]) + return cls( + event_id=str(data.get("event_id", f"shipment:{data['shipment_id']}:{recorded_at}")), + shipment_id=str(data["shipment_id"]), + order_id=str(data["order_id"]), + sku=str(data["sku"]), + warehouse=str(data["warehouse"]), + supplier_id=str(data["supplier_id"]), + current_location=str(data.get("current_location", "unknown")), + status=str(data.get("status", "in_transit")), + promised_delivery=str(data["promised_delivery"]), + estimated_arrival=str(data["estimated_arrival"]), + recorded_at=recorded_at, + delay_hours=float(data.get("delay_hours", 0.0)), + status_age_hours=float(data.get("status_age_hours", 0.0)), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class SupplierPerformanceEvent: + event_id: str + supplier_id: str + supplier_name: str + on_time_rate: float + defect_rate: float + breach_count_30d: int + observed_at: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SupplierPerformanceEvent: + observed_at = str(data["observed_at"]) + return cls( + event_id=str(data.get("event_id", f"supplier:{data['supplier_id']}:{observed_at}")), + supplier_id=str(data["supplier_id"]), + supplier_name=str(data.get("supplier_name", data["supplier_id"])), + on_time_rate=float(data["on_time_rate"]), + defect_rate=float(data.get("defect_rate", 0.0)), + breach_count_30d=int(data.get("breach_count_30d", 0)), + observed_at=observed_at, + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +SupplyEvent: TypeAlias = ( + PurchaseOrderEvent | InventoryEvent | ShipmentEvent | SupplierPerformanceEvent +) + + +def coerce_supply_event(data: SupplyEvent | dict[str, Any]) -> SupplyEvent: + if isinstance( + data, + (PurchaseOrderEvent, InventoryEvent, ShipmentEvent, SupplierPerformanceEvent), + ): + return data + if "shipment_id" in data: + return ShipmentEvent.from_dict(data) + if "current_stock" in data: + return InventoryEvent.from_dict(data) + if "on_time_rate" in data: + return SupplierPerformanceEvent.from_dict(data) + if "order_id" in data and "quantity" in data: + return PurchaseOrderEvent.from_dict(data) + raise ValueError(f"Unsupported supply event payload: {data}") + + +def supply_event_timestamp(event: SupplyEvent) -> str: + if isinstance(event, PurchaseOrderEvent): + return event.observed_at + if isinstance(event, InventoryEvent): + return event.updated_at + if isinstance(event, ShipmentEvent): + return event.recorded_at + return event.observed_at + + +@dataclass(frozen=True) +class NormalizedSupplyEvent: + event_id: str + event_type: str + timestamp: str + payload: dict[str, Any] + order_id: str | None = None + sku: str | None = None + warehouse: str | None = None + supplier_id: str | None = None + shipment_id: str | None = None + + @classmethod + def from_event(cls, event: SupplyEvent) -> NormalizedSupplyEvent: + if isinstance(event, PurchaseOrderEvent): + return cls( + event_id=event.event_id, + event_type="purchase_order", + timestamp=event.observed_at, + payload=event.to_dict(), + order_id=event.order_id, + sku=event.sku, + warehouse=event.warehouse, + supplier_id=event.supplier_id, + ) + if isinstance(event, InventoryEvent): + return cls( + event_id=event.event_id, + event_type="inventory", + timestamp=event.updated_at, + payload=event.to_dict(), + sku=event.sku, + warehouse=event.warehouse, + ) + if isinstance(event, ShipmentEvent): + return cls( + event_id=event.event_id, + event_type="shipment", + timestamp=event.recorded_at, + payload=event.to_dict(), + order_id=event.order_id, + sku=event.sku, + warehouse=event.warehouse, + supplier_id=event.supplier_id, + shipment_id=event.shipment_id, + ) + return cls( + event_id=event.event_id, + event_type="supplier_performance", + timestamp=event.observed_at, + payload=event.to_dict(), + supplier_id=event.supplier_id, + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class AnomalySignal: + rule_id: str + title: str + description: str + risk_level: str + triggered_at: str + source_event_id: str + order_id: str | None = None + sku: str | None = None + warehouse: str | None = None + supplier_id: str | None = None + shipment_id: str | None = None + metrics: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class ImpactAssessment: + rule_id: str + risk_level: str + affected_order_count: int + affected_sku_count: int + estimated_stockout_days: float + impact_summary: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class MitigationSuggestion: + rule_id: str + actions: list[str] + rationale: str + alternative_suppliers: list[str] = field(default_factory=list) + transfer_warehouse: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class OpenAlert: + alert_id: str + rule_id: str + title: str + summary: str + risk_level: str + triggered_at: str + order_id: str | None = None + sku: str | None = None + warehouse: str | None = None + supplier_id: str | None = None + shipment_id: str | None = None + affected_order_count: int = 0 + affected_sku_count: int = 0 + estimated_stockout_days: float = 0.0 + recommended_actions: list[str] = field(default_factory=list) + alternative_suppliers: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OpenAlert: + return cls( + alert_id=str(data["alert_id"]), + rule_id=str(data["rule_id"]), + title=str(data["title"]), + summary=str(data["summary"]), + risk_level=str(data["risk_level"]), + triggered_at=str(data["triggered_at"]), + order_id=data.get("order_id"), + sku=data.get("sku"), + warehouse=data.get("warehouse"), + supplier_id=data.get("supplier_id"), + shipment_id=data.get("shipment_id"), + affected_order_count=int(data.get("affected_order_count", 0)), + affected_sku_count=int(data.get("affected_sku_count", 0)), + estimated_stockout_days=float(data.get("estimated_stockout_days", 0.0)), + recommended_actions=list(data.get("recommended_actions", [])), + alternative_suppliers=list(data.get("alternative_suppliers", [])), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class SupplierRiskSummary: + supplier_id: str + supplier_name: str + risk_score: float + on_time_rate: float + defect_rate: float + breach_count_30d: int + open_alert_count: int + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SupplierRiskSummary: + return cls( + supplier_id=str(data["supplier_id"]), + supplier_name=str(data["supplier_name"]), + risk_score=float(data["risk_score"]), + on_time_rate=float(data["on_time_rate"]), + defect_rate=float(data["defect_rate"]), + breach_count_30d=int(data["breach_count_30d"]), + open_alert_count=int(data.get("open_alert_count", 0)), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class DashboardSnapshot: + generated_at: str + open_alert_count: int + high_risk_alert_count: int + low_stock_sku_count: int + delayed_shipment_count: int + overdue_order_count: int + high_risk_supplier_count: int + average_delay_hours: float + reallocation_suggestion_count: int + substitute_supplier_suggestion_count: int + impacted_order_count: int + top_risk_suppliers: list[SupplierRiskSummary] = field(default_factory=list) + top_shortage_skus: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "generated_at": self.generated_at, + "open_alert_count": self.open_alert_count, + "high_risk_alert_count": self.high_risk_alert_count, + "low_stock_sku_count": self.low_stock_sku_count, + "delayed_shipment_count": self.delayed_shipment_count, + "overdue_order_count": self.overdue_order_count, + "high_risk_supplier_count": self.high_risk_supplier_count, + "average_delay_hours": self.average_delay_hours, + "reallocation_suggestion_count": self.reallocation_suggestion_count, + "substitute_supplier_suggestion_count": self.substitute_supplier_suggestion_count, + "impacted_order_count": self.impacted_order_count, + "top_risk_suppliers": [item.to_dict() for item in self.top_risk_suppliers], + "top_shortage_skus": list(self.top_shortage_skus), + } + + +@dataclass(frozen=True) +class GatewayExplanationStatus: + reachable: bool + base_url: str + health_url: str + model: str | None = None + status_code: int | None = None + error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class AlertExplanation: + alert_id: str + rule_id: str + title: str + risk_level: str + explanation: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class RiskExplanationResult: + generated_at: str + gateway_status: GatewayExplanationStatus + dashboard_summary: str | None = None + alert_explanations: list[AlertExplanation] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "generated_at": self.generated_at, + "gateway_status": self.gateway_status.to_dict(), + "dashboard_summary": self.dashboard_summary, + "alert_explanations": [item.to_dict() for item in self.alert_explanations], + } + + +@dataclass(frozen=True) +class SupplyChainRunResult: + processed_event_count: int + alert_count: int + alerts: list[OpenAlert] + dashboard: DashboardSnapshot + supplier_risk_summaries: list[SupplierRiskSummary] + + def to_dict(self) -> dict[str, Any]: + return { + "processed_event_count": self.processed_event_count, + "alert_count": self.alert_count, + "alerts": [item.to_dict() for item in self.alerts], + "dashboard": self.dashboard.to_dict(), + "supplier_risk_summaries": [item.to_dict() for item in self.supplier_risk_summaries], + } diff --git a/apps/src/sage/apps/supply_chain_alert/operators.py b/apps/src/sage/apps/supply_chain_alert/operators.py new file mode 100644 index 0000000..0b09c6d --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/operators.py @@ -0,0 +1,481 @@ +"""SAGE operators for the supply chain alert dashboard MVP.""" + +from __future__ import annotations + +from typing import Any + +from sage.foundation import BatchFunction, MapFunction, SinkFunction + +from .models import ( + AnomalySignal, + ImpactAssessment, + InventoryEvent, + MitigationSuggestion, + NormalizedSupplyEvent, + OpenAlert, + PurchaseOrderEvent, + ShipmentEvent, + SupplierPerformanceEvent, + coerce_supply_event, +) +from .state_store import parse_timestamp + +STATE_SERVICE_NAME = "supply_chain_state" + + +def _risk_rank(level: str) -> int: + mapping = {"low": 1, "medium": 2, "high": 3, "critical": 4} + return mapping.get(level, 1) + + +def _max_risk_level(*levels: str) -> str: + resolved = max(levels, key=_risk_rank) + return resolved + + +class DemoEventSource(BatchFunction): + """BatchFunction source that emits demo or supplied events one by one.""" + + def __init__(self, events: list[Any], **kwargs) -> None: + super().__init__(**kwargs) + self.events = list(events) + self.index = 0 + + def execute(self) -> Any | None: + if self.index >= len(self.events): + return None + event = self.events[self.index] + self.index += 1 + return event + + +class NormalizeEventStep(MapFunction): + def execute( + self, + data: PurchaseOrderEvent + | InventoryEvent + | ShipmentEvent + | SupplierPerformanceEvent + | dict[str, Any], + ) -> NormalizedSupplyEvent: + event = coerce_supply_event(data) + return NormalizedSupplyEvent.from_event(event) + + +class UpdateStateStep(MapFunction): + def execute(self, data: NormalizedSupplyEvent) -> NormalizedSupplyEvent: + if data.event_type == "purchase_order": + self.call_service( + STATE_SERVICE_NAME, + PurchaseOrderEvent.from_dict(data.payload), + method="upsert_purchase_order", + ) + elif data.event_type == "inventory": + self.call_service( + STATE_SERVICE_NAME, + InventoryEvent.from_dict(data.payload), + method="upsert_inventory", + ) + elif data.event_type == "shipment": + self.call_service( + STATE_SERVICE_NAME, + ShipmentEvent.from_dict(data.payload), + method="upsert_shipment", + ) + elif data.event_type == "supplier_performance": + self.call_service( + STATE_SERVICE_NAME, + SupplierPerformanceEvent.from_dict(data.payload), + method="upsert_supplier_performance", + ) + return data + + +class DetectAnomalyStep(MapFunction): + def execute(self, data: NormalizedSupplyEvent) -> dict[str, Any]: + signals: list[AnomalySignal] = [] + + if data.event_type == "inventory": + signals.extend(self._detect_low_inventory(data)) + elif data.event_type == "purchase_order": + signals.extend(self._detect_order_backlog(data)) + signals.extend(self._detect_supplier_concentration(data)) + elif data.event_type == "shipment": + signals.extend(self._detect_shipment_delay(data)) + signals.extend(self._detect_shipment_stagnation(data)) + elif data.event_type == "supplier_performance": + signals.extend(self._detect_supplier_risk(data)) + + return {"event": data, "signals": signals} + + def _detect_low_inventory(self, data: NormalizedSupplyEvent) -> list[AnomalySignal]: + if data.warehouse is None or data.sku is None: + return [] + inventory = self.call_service( + STATE_SERVICE_NAME, + data.warehouse, + data.sku, + method="get_inventory_snapshot", + ) + if inventory is None or inventory.current_stock >= inventory.safety_stock: + return [] + + shortage = inventory.safety_stock - inventory.current_stock + ratio = inventory.current_stock / max(inventory.safety_stock, 1) + risk_level = "high" if ratio < 0.5 else "medium" + return [ + AnomalySignal( + rule_id="low_inventory", + title="安全库存不足", + description=( + f"仓库 {inventory.warehouse} 的 {inventory.sku} 当前库存 {inventory.current_stock}," + f"低于安全库存 {inventory.safety_stock}。" + ), + risk_level=risk_level, + triggered_at=data.timestamp, + source_event_id=data.event_id, + sku=inventory.sku, + warehouse=inventory.warehouse, + metrics={ + "current_stock": inventory.current_stock, + "safety_stock": inventory.safety_stock, + "shortage": shortage, + }, + ) + ] + + def _detect_order_backlog(self, data: NormalizedSupplyEvent) -> list[AnomalySignal]: + if data.order_id is None: + return [] + order = self.call_service(STATE_SERVICE_NAME, data.order_id, method="get_order") + if order is None: + return [] + if order.status.lower() in {"fulfilled", "delivered", "cancelled", "shipped"}: + return [] + + is_overdue = parse_timestamp(order.promised_delivery) < parse_timestamp(data.timestamp) + if not is_overdue: + return [] + + hours_overdue = round( + ( + parse_timestamp(data.timestamp) - parse_timestamp(order.promised_delivery) + ).total_seconds() + / 3600.0, + 2, + ) + risk_level = "high" if hours_overdue >= 2.0 else "medium" + return [ + AnomalySignal( + rule_id="order_backlog", + title="订单积压", + description=( + f"订单 {order.order_id} 已超过承诺交期 {hours_overdue} 小时,当前状态仍为 {order.status}。" + ), + risk_level=risk_level, + triggered_at=data.timestamp, + source_event_id=data.event_id, + order_id=order.order_id, + sku=order.sku, + warehouse=order.warehouse, + supplier_id=order.supplier_id, + metrics={"hours_overdue": hours_overdue, "quantity": order.quantity}, + ) + ] + + def _detect_supplier_concentration(self, data: NormalizedSupplyEvent) -> list[AnomalySignal]: + if data.order_id is None: + return [] + order = self.call_service(STATE_SERVICE_NAME, data.order_id, method="get_order") + if order is None: + return [] + + open_orders = self.call_service(STATE_SERVICE_NAME, method="get_open_orders") + same_sku_orders = [item for item in open_orders if item.sku == order.sku] + total_quantity = sum(item.quantity for item in same_sku_orders) + supplier_quantity = sum( + item.quantity for item in same_sku_orders if item.supplier_id == order.supplier_id + ) + if total_quantity < 80: + return [] + + dependency_ratio = supplier_quantity / max(total_quantity, 1) + if dependency_ratio < 0.75: + return [] + + risk_level = "high" if dependency_ratio >= 0.85 else "medium" + return [ + AnomalySignal( + rule_id="supplier_concentration", + title="单一供应商依赖过高", + description=( + f"SKU {order.sku} 的未完成采购量中有 {dependency_ratio:.0%} 来自供应商 {order.supplier_id}。" + ), + risk_level=risk_level, + triggered_at=data.timestamp, + source_event_id=data.event_id, + order_id=order.order_id, + sku=order.sku, + warehouse=order.warehouse, + supplier_id=order.supplier_id, + metrics={ + "dependency_ratio": round(dependency_ratio, 3), + "supplier_quantity": supplier_quantity, + "total_quantity": total_quantity, + }, + ) + ] + + def _detect_shipment_delay(self, data: NormalizedSupplyEvent) -> list[AnomalySignal]: + if data.shipment_id is None: + return [] + shipment = self.call_service(STATE_SERVICE_NAME, data.shipment_id, method="get_shipment") + if shipment is None: + return [] + arrival_late = parse_timestamp(shipment.estimated_arrival) > parse_timestamp( + shipment.promised_delivery + ) + if not arrival_late and shipment.delay_hours < 6.0: + return [] + + risk_level = "high" if shipment.delay_hours >= 12.0 else "medium" + return [ + AnomalySignal( + rule_id="shipment_delay", + title="在途延迟", + description=( + f"运单 {shipment.shipment_id} 预计晚于承诺交付,当前延迟 {shipment.delay_hours:.1f} 小时。" + ), + risk_level=risk_level, + triggered_at=data.timestamp, + source_event_id=data.event_id, + order_id=shipment.order_id, + sku=shipment.sku, + warehouse=shipment.warehouse, + supplier_id=shipment.supplier_id, + shipment_id=shipment.shipment_id, + metrics={"delay_hours": shipment.delay_hours}, + ) + ] + + def _detect_shipment_stagnation(self, data: NormalizedSupplyEvent) -> list[AnomalySignal]: + if data.shipment_id is None: + return [] + shipment = self.call_service(STATE_SERVICE_NAME, data.shipment_id, method="get_shipment") + if shipment is None: + return [] + if shipment.status.lower() in {"delivered", "completed"} or shipment.status_age_hours < 8.0: + return [] + + risk_level = "high" if shipment.status_age_hours >= 12.0 else "medium" + return [ + AnomalySignal( + rule_id="shipment_stagnation", + title="发货停滞", + description=( + f"运单 {shipment.shipment_id} 状态 {shipment.status} 已持续 {shipment.status_age_hours:.1f} 小时未变化。" + ), + risk_level=risk_level, + triggered_at=data.timestamp, + source_event_id=data.event_id, + order_id=shipment.order_id, + sku=shipment.sku, + warehouse=shipment.warehouse, + supplier_id=shipment.supplier_id, + shipment_id=shipment.shipment_id, + metrics={"status_age_hours": shipment.status_age_hours}, + ) + ] + + def _detect_supplier_risk(self, data: NormalizedSupplyEvent) -> list[AnomalySignal]: + if data.supplier_id is None: + return [] + supplier = self.call_service( + STATE_SERVICE_NAME, + data.supplier_id, + method="get_supplier_profile", + ) + if supplier is None: + return [] + risk_score = self.call_service(STATE_SERVICE_NAME, supplier, method="compute_supplier_risk") + if risk_score < 45.0: + return [] + + risk_level = "high" if risk_score >= 55.0 else "medium" + return [ + AnomalySignal( + rule_id="supplier_risk_deterioration", + title="供应商履约恶化", + description=( + f"供应商 {supplier.supplier_name} 近期准时率降至 {supplier.on_time_rate:.0%}," + f"且 30 天违约 {supplier.breach_count_30d} 次。" + ), + risk_level=risk_level, + triggered_at=data.timestamp, + source_event_id=data.event_id, + supplier_id=supplier.supplier_id, + metrics={ + "risk_score": risk_score, + "on_time_rate": supplier.on_time_rate, + "breach_count_30d": supplier.breach_count_30d, + }, + ) + ] + + +class AssessImpactStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + event = data["event"] + signals = list(data["signals"]) + assessments: list[ImpactAssessment] = [] + for signal in signals: + impacted_orders = self._resolve_impacted_orders(signal) + estimated_stockout_days = self._estimate_stockout_days(signal) + risk_level = signal.risk_level + if len(impacted_orders) >= 2 or estimated_stockout_days >= 2.0: + risk_level = _max_risk_level(signal.risk_level, "high") + assessments.append( + ImpactAssessment( + rule_id=signal.rule_id, + risk_level=risk_level, + affected_order_count=len(impacted_orders), + affected_sku_count=1 if signal.sku else 0, + estimated_stockout_days=estimated_stockout_days, + impact_summary=( + f"影响 {len(impacted_orders)} 个订单,预计库存风险窗口 {estimated_stockout_days:.1f} 天。" + ), + ) + ) + return {"event": event, "signals": signals, "assessments": assessments} + + def _resolve_impacted_orders(self, signal: AnomalySignal) -> list[PurchaseOrderEvent]: + open_orders = self.call_service(STATE_SERVICE_NAME, method="get_open_orders") + if signal.order_id is not None: + order = self.call_service(STATE_SERVICE_NAME, signal.order_id, method="get_order") + return [] if order is None else [order] + if signal.sku is None: + return [] + return [ + item + for item in open_orders + if item.sku == signal.sku + and (signal.warehouse is None or item.warehouse == signal.warehouse) + ] + + def _estimate_stockout_days(self, signal: AnomalySignal) -> float: + if signal.sku is None or signal.warehouse is None: + return 0.0 + inventory = self.call_service( + STATE_SERVICE_NAME, + signal.warehouse, + signal.sku, + method="get_inventory_snapshot", + ) + if inventory is None: + return 0.0 + + shortage = max(0, inventory.safety_stock - inventory.current_stock) + if shortage == 0: + return 0.0 + open_orders = self.call_service(STATE_SERVICE_NAME, method="get_open_orders") + same_sku_orders = [ + item + for item in open_orders + if item.sku == signal.sku and item.warehouse == signal.warehouse + ] + daily_demand = max(sum(item.quantity for item in same_sku_orders) / 7.0, 1.0) + return round(shortage / daily_demand, 2) + + +class RecommendMitigationStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + alerts: list[OpenAlert] = [] + for signal, assessment in zip(data["signals"], data["assessments"], strict=False): + suggestion = self._build_suggestion(signal, assessment) + alert = OpenAlert( + alert_id=f"{signal.source_event_id}:{signal.rule_id}", + rule_id=signal.rule_id, + title=signal.title, + summary=f"{signal.description} {assessment.impact_summary}", + risk_level=assessment.risk_level, + triggered_at=signal.triggered_at, + order_id=signal.order_id, + sku=signal.sku, + warehouse=signal.warehouse, + supplier_id=signal.supplier_id, + shipment_id=signal.shipment_id, + affected_order_count=assessment.affected_order_count, + affected_sku_count=assessment.affected_sku_count, + estimated_stockout_days=assessment.estimated_stockout_days, + recommended_actions=suggestion.actions, + alternative_suppliers=suggestion.alternative_suppliers, + ) + self.call_service(STATE_SERVICE_NAME, alert, method="save_alert") + alerts.append(alert) + return {"event": data["event"], "alerts": alerts} + + def _build_suggestion( + self, + signal: AnomalySignal, + assessment: ImpactAssessment, + ) -> MitigationSuggestion: + actions: list[str] = [] + alternative_suppliers: list[str] = [] + transfer_warehouse: str | None = None + + if signal.rule_id == "low_inventory" and signal.sku and signal.warehouse: + candidates = self.call_service( + STATE_SERVICE_NAME, + signal.sku, + signal.warehouse, + method="find_transfer_candidates", + ) + if candidates: + transfer_warehouse = candidates[0].warehouse + actions.append(f"从 {transfer_warehouse} 调拨 {signal.sku} 到 {signal.warehouse}") + actions.append("对缺口 SKU 发起加急补货") + + if signal.rule_id in {"shipment_delay", "shipment_stagnation"}: + actions.append("联系承运方加急处理,并重算对客户承诺交期") + + if signal.rule_id == "order_backlog": + actions.append("将积压订单提升到优先履约队列") + + if ( + signal.rule_id in {"supplier_concentration", "supplier_risk_deterioration"} + and signal.sku + ): + supplier_candidates = self.call_service( + STATE_SERVICE_NAME, + signal.sku, + signal.supplier_id, + method="list_supplier_candidates_for_sku", + ) + alternative_suppliers = [item.supplier_id for item in supplier_candidates[:2]] + if alternative_suppliers: + actions.append("切换部分后续采购到替代供应商: " + ", ".join(alternative_suppliers)) + actions.append("降低单一供应商采购集中度") + + if not actions: + actions.append("人工复核当前异常并更新处置计划") + + rationale = ( + f"规则 {signal.rule_id} 当前风险等级为 {assessment.risk_level}," + f"影响订单 {assessment.affected_order_count} 个。" + ) + return MitigationSuggestion( + rule_id=signal.rule_id, + actions=actions, + rationale=rationale, + alternative_suppliers=alternative_suppliers, + transfer_warehouse=transfer_warehouse, + ) + + +class AlertCollectorSink(SinkFunction): + def __init__(self, results: list[OpenAlert], **kwargs) -> None: + super().__init__(**kwargs) + self.results = results + + def execute(self, data: dict[str, Any]) -> None: + self.results.extend(data.get("alerts", [])) diff --git a/apps/src/sage/apps/supply_chain_alert/service.py b/apps/src/sage/apps/supply_chain_alert/service.py new file mode 100644 index 0000000..fe573ed --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/service.py @@ -0,0 +1,177 @@ +"""Service facade and optional FastAPI adapter for the supply chain alert MVP.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any + +try: + from fastapi import FastAPI + from fastapi.responses import HTMLResponse +except ImportError: # pragma: no cover - optional dependency for HTTP adapter only + FastAPI = None + HTMLResponse = None + +from .demo_data import build_demo_event_summary, build_demo_supply_events +from .llm import SupplyChainRiskExplainer +from .models import ( + DashboardSnapshot, + RiskExplanationResult, + SupplyChainRunResult, + SupplyEvent, + coerce_supply_event, +) +from .ui import render_dashboard_page +from .workflow import SupplyChainWorkflowRunner + + +def _to_payload(value: Any) -> Any: + if hasattr(value, "to_dict"): + return value.to_dict() + if is_dataclass(value): + return asdict(value) + if isinstance(value, list): + return [_to_payload(item) for item in value] + if isinstance(value, dict): + return {key: _to_payload(item) for key, item in value.items()} + return value + + +def _resolve_last_timestamp(events: list[SupplyEvent]) -> str | None: + if not events: + return None + timestamps = [] + for event in events: + if hasattr(event, "observed_at"): + timestamps.append(event.observed_at) + elif hasattr(event, "updated_at"): + timestamps.append(event.updated_at) + elif hasattr(event, "recorded_at"): + timestamps.append(event.recorded_at) + return max(timestamps) if timestamps else None + + +class SupplyChainAlertApplicationService: + """High-level service interface for demo ingest and dashboard queries.""" + + def __init__( + self, + storage_path: str | Path | None = None, + risk_explainer: SupplyChainRiskExplainer | None = None, + ) -> None: + self.workflow = SupplyChainWorkflowRunner.from_storage_path(storage_path=storage_path) + self._risk_explainer = risk_explainer + + def reset(self) -> None: + self.workflow.reset() + + def ingest_events(self, events: list[SupplyEvent | dict[str, Any]]) -> SupplyChainRunResult: + resolved_events = [coerce_supply_event(item) for item in events] + alerts = self.workflow.ingest_events(resolved_events) + dashboard = self.workflow.get_dashboard(as_of=_resolve_last_timestamp(resolved_events)) + supplier_risk_summaries = self.workflow.list_supplier_risk_summary(limit=5) + return SupplyChainRunResult( + processed_event_count=len(resolved_events), + alert_count=len(alerts), + alerts=alerts, + dashboard=dashboard, + supplier_risk_summaries=supplier_risk_summaries, + ) + + def run_demo(self, *, reset: bool = True) -> SupplyChainRunResult: + if reset: + self.reset() + return self.ingest_events(build_demo_supply_events()) + + def get_dashboard(self) -> DashboardSnapshot: + return self.workflow.get_dashboard() + + def list_open_alerts(self): + return self.workflow.list_open_alerts() + + def get_supplier_risk_summary(self): + return self.workflow.list_supplier_risk_summary(limit=5) + + def explain_current_risks(self, *, max_alerts: int = 5) -> RiskExplanationResult: + explainer = self._risk_explainer or SupplyChainRiskExplainer() + self._risk_explainer = explainer + return explainer.explain_current_risks( + dashboard=self.get_dashboard(), + alerts=self.list_open_alerts(), + supplier_risk_summaries=self.get_supplier_risk_summary(), + max_alerts=max_alerts, + ) + + def get_demo_summary(self) -> dict[str, int]: + return build_demo_event_summary() + + +def create_demo_application_service( + storage_path: str | Path | None = None, +) -> SupplyChainAlertApplicationService: + return SupplyChainAlertApplicationService(storage_path=storage_path) + + +def create_fastapi_app( + service: SupplyChainAlertApplicationService | None = None, +) -> FastAPI: + if FastAPI is None: + raise RuntimeError( + "FastAPI is not installed. Install fastapi to use the supply chain HTTP adapter.", + ) + + runtime_service = service or SupplyChainAlertApplicationService() + app = FastAPI(title="SAGE Supply Chain Alert Dashboard", version="0.1.0") + + @app.get("/") + def root() -> dict[str, Any]: + return { + "name": "SAGE Supply Chain Alert Dashboard", + "version": "0.1.0", + "routes": [ + "/health", + "/dashboard", + "/dashboard/ui", + "/alerts/open", + "/alerts/explanations", + "/suppliers/risk", + "/events/ingest", + "/demo/reset-and-run", + ], + } + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + @app.post("/events/ingest") + def ingest_events(payload: list[dict[str, Any]]) -> dict[str, Any]: + result = runtime_service.ingest_events(payload) + return _to_payload(result) + + @app.get("/dashboard") + def get_dashboard() -> dict[str, Any]: + return _to_payload(runtime_service.get_dashboard()) + + @app.get("/dashboard/ui", response_class=HTMLResponse) + def get_dashboard_ui() -> HTMLResponse: + return HTMLResponse(render_dashboard_page()) + + @app.get("/alerts/open") + def list_open_alerts() -> list[dict[str, Any]]: + return _to_payload(runtime_service.list_open_alerts()) + + @app.get("/alerts/explanations") + def explain_current_risks(max_alerts: int = 5) -> dict[str, Any]: + return _to_payload(runtime_service.explain_current_risks(max_alerts=max_alerts)) + + @app.get("/suppliers/risk") + def get_supplier_risk_summary() -> list[dict[str, Any]]: + return _to_payload(runtime_service.get_supplier_risk_summary()) + + @app.post("/demo/reset-and-run") + def run_demo() -> dict[str, Any]: + return _to_payload(runtime_service.run_demo(reset=True)) + + return app diff --git a/apps/src/sage/apps/supply_chain_alert/state_store.py b/apps/src/sage/apps/supply_chain_alert/state_store.py new file mode 100644 index 0000000..8d61222 --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/state_store.py @@ -0,0 +1,275 @@ +"""Explicit in-memory state store for the supply chain alert dashboard.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from .models import ( + DashboardSnapshot, + InventoryEvent, + OpenAlert, + PurchaseOrderEvent, + ShipmentEvent, + SupplierPerformanceEvent, + SupplierRiskSummary, +) + + +def parse_timestamp(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +class InMemorySupplyChainStateStore: + """Pluggable in-memory state store with optional JSON persistence.""" + + def __init__(self, storage_path: str | Path | None = None) -> None: + self.storage_path = None if storage_path is None else Path(storage_path) + self.inventories: dict[tuple[str, str], InventoryEvent] = {} + self.orders: dict[str, PurchaseOrderEvent] = {} + self.shipments: dict[str, ShipmentEvent] = {} + self.suppliers: dict[str, SupplierPerformanceEvent] = {} + self.alerts: dict[str, OpenAlert] = {} + + if self.storage_path is not None and self.storage_path.exists(): + self._load() + + def reset(self) -> None: + self.inventories = {} + self.orders = {} + self.shipments = {} + self.suppliers = {} + self.alerts = {} + self._persist() + + def upsert_inventory(self, inventory: InventoryEvent) -> None: + self.inventories[(inventory.warehouse, inventory.sku)] = inventory + self._persist() + + def upsert_purchase_order(self, order: PurchaseOrderEvent) -> None: + self.orders[order.order_id] = order + self._persist() + + def upsert_shipment(self, shipment: ShipmentEvent) -> None: + self.shipments[shipment.shipment_id] = shipment + self._persist() + + def upsert_supplier_performance(self, supplier: SupplierPerformanceEvent) -> None: + self.suppliers[supplier.supplier_id] = supplier + self._persist() + + def get_inventory_snapshot(self, warehouse: str, sku: str) -> InventoryEvent | None: + return self.inventories.get((warehouse, sku)) + + def list_inventory_snapshots(self, sku: str | None = None) -> list[InventoryEvent]: + inventories = list(self.inventories.values()) + if sku is None: + return inventories + return [item for item in inventories if item.sku == sku] + + def get_order(self, order_id: str) -> PurchaseOrderEvent | None: + return self.orders.get(order_id) + + def get_open_orders(self) -> list[PurchaseOrderEvent]: + closed_statuses = {"fulfilled", "delivered", "cancelled"} + return [item for item in self.orders.values() if item.status.lower() not in closed_statuses] + + def get_shipment(self, shipment_id: str) -> ShipmentEvent | None: + return self.shipments.get(shipment_id) + + def list_shipments(self) -> list[ShipmentEvent]: + return list(self.shipments.values()) + + def get_supplier_profile(self, supplier_id: str) -> SupplierPerformanceEvent | None: + return self.suppliers.get(supplier_id) + + def list_supplier_profiles(self) -> list[SupplierPerformanceEvent]: + return list(self.suppliers.values()) + + def save_alert(self, alert: OpenAlert) -> None: + self.alerts[alert.alert_id] = alert + self._persist() + + def list_open_alerts(self) -> list[OpenAlert]: + return sorted(self.alerts.values(), key=lambda item: item.triggered_at) + + def find_transfer_candidates(self, sku: str, exclude_warehouse: str) -> list[InventoryEvent]: + candidates = [ + item + for item in self.list_inventory_snapshots(sku) + if item.warehouse != exclude_warehouse and item.current_stock > item.safety_stock + ] + return sorted( + candidates, + key=lambda item: (item.current_stock - item.safety_stock), + reverse=True, + ) + + def list_supplier_candidates_for_sku( + self, + sku: str, + exclude_supplier_id: str | None = None, + ) -> list[SupplierRiskSummary]: + supplier_ids = {item.supplier_id for item in self.get_open_orders() if item.sku == sku} + if exclude_supplier_id is not None: + supplier_ids.discard(exclude_supplier_id) + + all_summaries = self.build_supplier_risk_summary(limit=None) + if supplier_ids: + summaries = [ + summary for summary in all_summaries if summary.supplier_id in supplier_ids + ] + elif exclude_supplier_id is not None: + summaries = [ + summary for summary in all_summaries if summary.supplier_id != exclude_supplier_id + ] + else: + summaries = list(all_summaries) + + return sorted(summaries, key=lambda item: item.risk_score) + + def compute_supplier_risk(self, supplier: SupplierPerformanceEvent) -> float: + lateness_risk = (1.0 - supplier.on_time_rate) * 55.0 + defect_risk = supplier.defect_rate * 100.0 * 1.2 + breach_risk = float(supplier.breach_count_30d) * 6.0 + return round(min(100.0, lateness_risk + defect_risk + breach_risk), 2) + + def build_supplier_risk_summary(self, limit: int | None = 5) -> list[SupplierRiskSummary]: + alerts_by_supplier: dict[str, int] = {} + for alert in self.alerts.values(): + if alert.supplier_id is None: + continue + alerts_by_supplier[alert.supplier_id] = alerts_by_supplier.get(alert.supplier_id, 0) + 1 + + summaries = [ + SupplierRiskSummary( + supplier_id=item.supplier_id, + supplier_name=item.supplier_name, + risk_score=self.compute_supplier_risk(item), + on_time_rate=item.on_time_rate, + defect_rate=item.defect_rate, + breach_count_30d=item.breach_count_30d, + open_alert_count=alerts_by_supplier.get(item.supplier_id, 0), + ) + for item in self.suppliers.values() + ] + summaries.sort(key=lambda item: item.risk_score, reverse=True) + return summaries if limit is None else summaries[:limit] + + def build_dashboard_snapshot(self, as_of: str | None = None) -> DashboardSnapshot: + resolved_as_of = as_of or self._infer_latest_timestamp() + snapshot_time = parse_timestamp(resolved_as_of) + open_alerts = self.list_open_alerts() + open_orders = self.get_open_orders() + delayed_shipments = [item for item in self.shipments.values() if item.delay_hours > 0.0] + overdue_orders = [ + item for item in open_orders if parse_timestamp(item.promised_delivery) < snapshot_time + ] + low_stock_items = [ + item for item in self.inventories.values() if item.current_stock < item.safety_stock + ] + top_shortage_skus = [ + item.sku + for item in sorted( + low_stock_items, + key=lambda inventory: (inventory.safety_stock - inventory.current_stock), + reverse=True, + ) + ][:3] + supplier_risk = self.build_supplier_risk_summary(limit=3) + average_delay = 0.0 + if delayed_shipments: + average_delay = round( + sum(item.delay_hours for item in delayed_shipments) / len(delayed_shipments), + 2, + ) + + impacted_orders = {alert.order_id for alert in open_alerts if alert.order_id is not None} + impacted_orders.update( + order.order_id + for order in overdue_orders + if any(alert.sku == order.sku for alert in open_alerts if alert.sku is not None) + ) + + return DashboardSnapshot( + generated_at=resolved_as_of, + open_alert_count=len(open_alerts), + high_risk_alert_count=sum( + 1 for item in open_alerts if item.risk_level in {"high", "critical"} + ), + low_stock_sku_count=len(low_stock_items), + delayed_shipment_count=len(delayed_shipments), + overdue_order_count=len(overdue_orders), + high_risk_supplier_count=sum(1 for item in supplier_risk if item.risk_score >= 45.0), + average_delay_hours=average_delay, + reallocation_suggestion_count=sum( + 1 + for item in open_alerts + if any( + "调拨" in action or "transfer" in action for action in item.recommended_actions + ) + ), + substitute_supplier_suggestion_count=sum( + 1 for item in open_alerts if item.alternative_suppliers + ), + impacted_order_count=len(impacted_orders), + top_risk_suppliers=supplier_risk, + top_shortage_skus=top_shortage_skus, + ) + + def _infer_latest_timestamp(self) -> str: + timestamps: list[str] = [] + timestamps.extend(item.updated_at for item in self.inventories.values()) + timestamps.extend(item.observed_at for item in self.orders.values()) + timestamps.extend(item.recorded_at for item in self.shipments.values()) + timestamps.extend(item.observed_at for item in self.suppliers.values()) + timestamps.extend(item.triggered_at for item in self.alerts.values()) + if not timestamps: + return datetime.utcnow().replace(microsecond=0).isoformat() + return max(timestamps) + + def _persist(self) -> None: + if self.storage_path is None: + return + + if self.storage_path.parent and not self.storage_path.parent.exists(): + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + + payload = { + "inventories": [item.to_dict() for item in self.inventories.values()], + "orders": [item.to_dict() for item in self.orders.values()], + "shipments": [item.to_dict() for item in self.shipments.values()], + "suppliers": [item.to_dict() for item in self.suppliers.values()], + "alerts": [item.to_dict() for item in self.alerts.values()], + } + self.storage_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _load(self) -> None: + if self.storage_path is None: + return + + payload = json.loads(self.storage_path.read_text(encoding="utf-8")) + self.inventories = { + (item["warehouse"], item["sku"]): InventoryEvent.from_dict(item) + for item in payload.get("inventories", []) + } + self.orders = { + item["order_id"]: PurchaseOrderEvent.from_dict(item) + for item in payload.get("orders", []) + } + self.shipments = { + item["shipment_id"]: ShipmentEvent.from_dict(item) + for item in payload.get("shipments", []) + } + self.suppliers = { + item["supplier_id"]: SupplierPerformanceEvent.from_dict(item) + for item in payload.get("suppliers", []) + } + self.alerts = { + item["alert_id"]: OpenAlert.from_dict(item) for item in payload.get("alerts", []) + } diff --git a/apps/src/sage/apps/supply_chain_alert/ui.py b/apps/src/sage/apps/supply_chain_alert/ui.py new file mode 100644 index 0000000..702af7f --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/ui.py @@ -0,0 +1,993 @@ +"""Multi-role web UI for the supply chain alert dashboard.""" + +from __future__ import annotations + + +def render_dashboard_page() -> str: + return """ + + + + + SAGE Supply Chain Alert Dashboard + + + +
+
+
+

Supply Chain Command Surface

+

供应链异常预警看板

+

+ 这个页面在同一套数据上切换多角色视角。运营总控、采购协调、仓配调度和供应商管理都能看到各自最该处理的问题、动作队列与结构化解释。 +

+
+ +
+ +
+
+ +
+
+
+

Role Lenses

+

运营总控

+

等待数据加载。

+
+
+
+
+
+

角色摘要

+

等待数据加载。

+
+
    +
    +
    +
    +
    + +
    +

    全局态势

    +
    等待数据加载。
    +
    +
    + +
    +

    角色关注预警

    +
    还没有预警数据。
    +
    + +
    +

    角色关注供应商

    +
    还没有供应商风险数据。
    +
    + +
    +

    自然语言解释

    +
    尚未请求解释。
    +
    +
    即使网关暂时不可用,角色摘要和动作队列仍会继续基于规则结果工作。
    +
    +
    +
    + + +
    + + + + +""" diff --git a/apps/src/sage/apps/supply_chain_alert/workflow.py b/apps/src/sage/apps/supply_chain_alert/workflow.py new file mode 100644 index 0000000..d96d8f4 --- /dev/null +++ b/apps/src/sage/apps/supply_chain_alert/workflow.py @@ -0,0 +1,123 @@ +"""SAGE workflow orchestration for the supply chain alert dashboard MVP.""" + +from __future__ import annotations + +from pathlib import Path + +from sage.runtime import BaseService, LocalEnvironment + +from .models import DashboardSnapshot, OpenAlert, SupplierRiskSummary, SupplyEvent +from .operators import ( + STATE_SERVICE_NAME, + AlertCollectorSink, + AssessImpactStep, + DemoEventSource, + DetectAnomalyStep, + NormalizeEventStep, + RecommendMitigationStep, + UpdateStateStep, +) +from .state_store import InMemorySupplyChainStateStore + + +class SupplyChainStateService(BaseService): + """Runtime service exposing explicit state operations to workflow nodes.""" + + def __init__(self, store: InMemorySupplyChainStateStore) -> None: + super().__init__() + self.store = store + + def reset(self) -> None: + self.store.reset() + + def upsert_inventory(self, inventory) -> None: + self.store.upsert_inventory(inventory) + + def upsert_purchase_order(self, order) -> None: + self.store.upsert_purchase_order(order) + + def upsert_shipment(self, shipment) -> None: + self.store.upsert_shipment(shipment) + + def upsert_supplier_performance(self, supplier) -> None: + self.store.upsert_supplier_performance(supplier) + + def get_inventory_snapshot(self, warehouse: str, sku: str): + return self.store.get_inventory_snapshot(warehouse, sku) + + def get_order(self, order_id: str): + return self.store.get_order(order_id) + + def get_open_orders(self): + return self.store.get_open_orders() + + def get_shipment(self, shipment_id: str): + return self.store.get_shipment(shipment_id) + + def get_supplier_profile(self, supplier_id: str): + return self.store.get_supplier_profile(supplier_id) + + def compute_supplier_risk(self, supplier) -> float: + return self.store.compute_supplier_risk(supplier) + + def find_transfer_candidates(self, sku: str, exclude_warehouse: str): + return self.store.find_transfer_candidates(sku, exclude_warehouse) + + def list_supplier_candidates_for_sku(self, sku: str, exclude_supplier_id: str | None = None): + return self.store.list_supplier_candidates_for_sku(sku, exclude_supplier_id) + + def save_alert(self, alert) -> None: + self.store.save_alert(alert) + + def list_open_alerts(self) -> list[OpenAlert]: + return self.store.list_open_alerts() + + def build_dashboard_snapshot(self, as_of: str | None = None) -> DashboardSnapshot: + return self.store.build_dashboard_snapshot(as_of=as_of) + + def build_supplier_risk_summary(self, limit: int | None = 5) -> list[SupplierRiskSummary]: + return self.store.build_supplier_risk_summary(limit=limit) + + +class SupplyChainWorkflowRunner: + """Thin workflow runner that rebuilds a LocalEnvironment per ingest operation.""" + + def __init__(self, state_store: InMemorySupplyChainStateStore | None = None) -> None: + self.state_store = state_store or InMemorySupplyChainStateStore() + + @classmethod + def from_storage_path(cls, storage_path: str | Path | None = None) -> SupplyChainWorkflowRunner: + return cls(InMemorySupplyChainStateStore(storage_path=storage_path)) + + def _build_environment(self, name: str) -> LocalEnvironment: + environment = LocalEnvironment(name) + environment.set_console_log_level("ERROR") + environment.register_service(STATE_SERVICE_NAME, SupplyChainStateService, self.state_store) + return environment + + def reset(self) -> None: + self.state_store.reset() + + def ingest_events(self, events: list[SupplyEvent | dict]) -> list[OpenAlert]: + results: list[OpenAlert] = [] + environment = self._build_environment("supply_chain_alert_ingest") + ( + environment.from_batch(DemoEventSource, events=events) + .map(NormalizeEventStep) + .map(UpdateStateStep) + .map(DetectAnomalyStep) + .map(AssessImpactStep) + .map(RecommendMitigationStep) + .sink(AlertCollectorSink, results=results) + ) + environment.submit(autostop=True) + return results + + def get_dashboard(self, as_of: str | None = None) -> DashboardSnapshot: + return self.state_store.build_dashboard_snapshot(as_of=as_of) + + def list_open_alerts(self) -> list[OpenAlert]: + return self.state_store.list_open_alerts() + + def list_supplier_risk_summary(self, limit: int | None = 5) -> list[SupplierRiskSummary]: + return self.state_store.build_supplier_risk_summary(limit=limit) diff --git a/apps/src/sage/apps/ticket_triage/README.md b/apps/src/sage/apps/ticket_triage/README.md new file mode 100644 index 0000000..4c8279d --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/README.md @@ -0,0 +1,356 @@ +# 客服工单分诊 MVP + +这个应用是一个基于 SAGE 构建的、可直接运行的客服工单分诊 Demo。 + +这份 README 是当前唯一的事实来源,同时覆盖实现计划和仓库中已经落地的代码。`docs` 目录下的规划文档现在只保留为入口说明,不应该再和这里的内容分叉。 + +## 目标 + +这个 MVP 主要聚焦五件事: + +1. 从邮件、表单和在线聊天导入工单 +1. 识别工单意图和紧急程度 +1. 召回 FAQ 和历史处理案例 +1. 将工单路由到合适的支持队列 +1. 持久化工单状态,支持后续查询 + +这个 Demo 的目标不是简单打印分类结果,而是要求整条业务链路真正运行在 SAGE runtime 上,并通过显式的算子和状态服务来组织执行。 + +## 必须调用的 SAGE 接口 + +当前 MVP 显式使用了这些 SAGE 接口: + +```python +from sage.foundation import BatchFunction, MapFunction, SinkFunction +from sage.runtime import BaseService, LocalEnvironment +``` + +在这个 Demo 里,它们的职责分别是: + +- `BatchFunction`:把工单事件送入工作流 +- `MapFunction`:完成标准化、分类、紧急度评分、召回、路由和持久化 +- `SinkFunction`:收集最终分诊结果 +- `BaseService`:向工作流节点暴露状态仓操作 +- `LocalEnvironment`:构建并执行工单分诊 pipeline + +这意味着这个应用并不是一个普通 Python 脚本外面套一层包名,而是明确围绕 SAGE workflow 执行路径来实现的。 + +## 当前结构 + +仓库中的实际代码结构如下: + +```text +apps/src/sage/apps/ticket_triage/ +├── __init__.py +├── README.md +├── demo_data.py +├── models.py +├── operators.py +├── service.py +├── state_store.py +└── workflow.py + +examples/ +└── run_ticket_triage.py + +apps/tests/ticket_triage/ +├── test_api.py +├── test_routing_rules.py +├── test_service.py +└── test_workflow.py +``` + +各文件职责如下: + +- `models.py`:定义工单、FAQ、历史案例、分诊结果、队列摘要和状态快照等模型 +- `demo_data.py`:提供演示工单、FAQ 条目、历史案例和统计摘要 +- `state_store.py`:带可选 JSON 持久化的内存状态仓 +- `operators.py`:实现 SAGE 的 source、map、sink 算子以及具体分诊规则 +- `workflow.py`:负责 `LocalEnvironment` 编排和 `BaseService` 注册 +- `service.py`:应用服务门面,以及可选的 FastAPI 适配层 +- `ui.py`:中文浏览器看板页面,负责把 API 结果渲染成演示界面 +- `examples/run_ticket_triage.py`:CLI 演示入口 +- `tests/ticket_triage`:覆盖 workflow、service、路由规则和 API 的定向测试 + +## Workflow 设计 + +当前实现的 pipeline 如下: + +```text +DemoTicketSource + -> NormalizeTicketStep + -> EnrichCustomerContextStep + -> ClassifyIntentStep + -> ScoreUrgencyStep + -> RecallSimilarCasesStep + -> DecideRouteStep + -> DraftReplyStep + -> PersistTicketStateStep + -> ResultCollectorSink +``` + +代码映射关系如下: + +- workflow 的 source 和整体编排在 `workflow.py` +- 每个步骤的具体实现都在 `operators.py` +- 状态服务的注册在 `workflow.py` 中通过 `TicketTriageStateService` 完成 +- workflow 节点中的状态访问通过 `call_service` 完成,而不是直接修改状态仓对象 + +这部分是整个设计里最关键的点,因为它保证这个 Demo 真正在展示 SAGE runtime 的使用方式,而不是只是借了包路径。 + +## 已实现的状态模型 + +当前状态仓维护了四类数据: + +- 参考 FAQ 条目 +- 历史处理案例 +- 按工单维度保存的状态快照 +- 最终分诊结果 + +当前暴露给 workflow 的关键服务方法包括: + +- `load_reference_data` +- `save_ticket_snapshot` +- `get_ticket_snapshot` +- `build_status_snapshot` +- `list_customer_recent_tickets` +- `search_knowledge_articles` +- `search_similar_resolutions` +- `append_triage_result` +- `assign_team_queue` +- `list_queue_summary` +- `list_open_high_priority_tickets` + +这个实现和最初计划在方向上保持一致,同时规模也足够小,适合作为 MVP。 + +## 已实现的规则 + +当前 MVP 使用的是显式、可解释的规则,而不是训练好的分类模型。 + +已经支持的意图类别: + +- `login_issue` +- `refund_request` +- `invoice_issue` +- `order_delay` +- `technical_support` +- `complaint_escalation` +- `general_inquiry` + +已经实现的紧急度信号: + +- 故障类或技术类关键词会提高分数 +- 退款和投诉类场景会更激进地提高分数 +- “等待中”“升级”“立即处理”等紧急措辞会提高分数 +- 同一客户重复提交工单会提高分数 +- `vip` 和 `enterprise` 客户会提高分数 +- 带附件的工单会提高分数,因为通常意味着需要人工复核 +- `chat` 渠道会有一个小幅加分,因为它天然带有即时响应压力 + +已经实现的路由行为: + +- 投诉升级或 `critical` 优先级直接路由到 `duty_manager` +- 退款和发票问题路由到 `billing_ops` +- 订单延迟路由到 `order_ops` +- 登录和技术问题路由到 `technical_support` +- FAQ 高匹配且优先级较低时,可以触发自动回复 + +## 已实现内容与规划内容的区别 + +当前已经完成的内容: + +- CLI 演示入口 +- 工单状态持久化 +- FAQ 与历史案例召回 +- 队列摘要查询 +- workflow、service、路由规则和 API 的定向测试 +- `service.py` 中的可选 FastAPI app factory + +尚未实现、但仍在规划中的内容: + +- 真正的外部 FAQ 检索或向量召回 +- 通过 `sage.serving` 实现的 LLM 自动回复润色 +- 更完整的 HTTP 或 Web dashboard 层 +- SLA 计时器和超时预警 +- 客服团队间的负载均衡 + +这里需要明确区分,因为最初的规划文档里还包含了后续阶段能力。那些能力仍然是有效的路线图,但并不在当前代码路径里。 + +## 怎么读代码 + +如果你想快速理解实现,建议按下面顺序读: + +1. `examples/run_ticket_triage.py` +1. `service.py` +1. `workflow.py` +1. `operators.py` +1. `state_store.py` +1. `demo_data.py` +1. `models.py` + +这个顺序和真实执行路径是一致的: + +- runner 创建应用服务 +- service 加载演示数据并调用 `ingest_tickets` +- workflow 构建 `LocalEnvironment` 并注册 `TicketTriageStateService` +- 各个 operator 逐步转换工单数据 +- state store 最终持久化快照和队列状态 + +如果你只想回答某一类问题,也可以按这个入口顺序中途停下: + +- 如果你想看 SAGE 是怎么被调用的,读到 `workflow.py` 就够了 +- 如果你想看为什么某个工单被分类或路由成这样,直接看 `operators.py` +- 如果你想看状态是怎么存和怎么查的,就看 `state_store.py` + +## 基于断点的调试方式 + +想理解这个 Demo,最稳的方式不是先看最终 JSON,而是顺着数据流打断点。 + +推荐断点顺序: + +1. `examples/run_ticket_triage.py` 里的 `main` +1. `service.py` 里的 `run_demo` 和 `ingest_tickets` +1. `workflow.py` 里的 `ingest_tickets` +1. `NormalizeTicketStep` 里的 `execute` +1. `ClassifyIntentStep` 里的 `execute` +1. `ScoreUrgencyStep` 里的 `execute` +1. `DecideRouteStep` 里的 `execute` +1. `PersistTicketStateStep` 里的 `execute` +1. `state_store.py` 里的 `save_ticket_snapshot` + +每一步建议重点看这些数据: + +- 在 `NormalizeTicketStep`:确认原始 `TicketEvent` 和生成的 `normalized_text` +- 在 `ClassifyIntentStep`:看 `recent_tickets`、命中的关键词和最终意图 +- 在 `ScoreUrgencyStep`:看哪些规则参与了打分,以及最终优先级是怎么出来的 +- 在 `DecideRouteStep`:看最终决策主要来自意图、紧急度,还是 FAQ 匹配强度 +- 在 `PersistTicketStateStep`:看最终的 `reason_trace`,它就是结果解释链 +- 在 `save_ticket_snapshot`:看真正被持久化下来的快照内容 + +这样你就能根据运行时状态变化一步步推导出结果: + +```text +输入工单 + -> 标准化文本 + -> 命中的意图关键词 + -> 紧急度原因与分数 + -> 路由决策 + -> 持久化快照 + -> 最终结果 +``` + +如果你发现结果不对,不要先拿最终标签去硬比。先判断是哪个 operator 第一次把结果带偏了。在这个 Demo 里,通常会落在这三个位置之一: + +- `ClassifyIntentStep`:关键词重叠太弱或太宽,导致意图选错 +- `ScoreUrgencyStep`:分数被推到了错误的优先级档位 +- `DecideRouteStep`:意图和紧急度组合是对的,但路由决策不符合预期 + +## 运行方式 + +在仓库根目录执行: + +```bash +python examples/run_ticket_triage.py +``` + +如果你想看结构化输出: + +```bash +python examples/run_ticket_triage.py --json +``` + +如果你想把状态落盘: + +```bash +python examples/run_ticket_triage.py --storage-path .sage/ticket-triage-state.json +``` + +## API + +在仓库根目录启动 FastAPI 服务: + +```bash +python examples/run_ticket_triage_api.py +``` + +自定义 host 和 port: + +```bash +python examples/run_ticket_triage_api.py --host 0.0.0.0 --port 8020 +``` + +如果你想在 API 模式下持久化状态: + +```bash +python examples/run_ticket_triage_api.py --storage-path .sage/ticket-triage-state.json +``` + +当前可用路由: + +- `GET /docs` +- `GET /redoc` +- `GET /openapi.json` +- `GET /` +- `GET /health` +- `GET /dashboard` +- `GET /dashboard/ui` +- `GET /tickets` +- `POST /tickets/ingest` +- `GET /tickets/high-priority` +- `GET /tickets/{ticket_id}` +- `GET /queues` +- `POST /demo/reset-and-run` + +## 浏览器与 Swagger + +启动 API 服务后,最直接的浏览器入口是: + +- `http://127.0.0.1:8010/` +- `http://127.0.0.1:8010/dashboard/ui` +- `http://127.0.0.1:8010/docs` +- `http://127.0.0.1:8010/redoc` + +推荐的使用顺序: + +1. 如果是对外演示,优先打开 `/dashboard/ui` +1. 点击“加载演示数据”按钮,把演示工单写入状态仓 +1. 查看顶部指标卡、队列分布和高优先级工单列表 +1. 点击结果列表中的工单编号,查看单票详情和原因链路 +1. 如果要模拟新场景,可以在页面底部直接提交一条自定义工单 + +Swagger 使用说明: + +- `/docs` 最适合直接在浏览器里交互式试接口 +- `/redoc` 更适合整体浏览 API 文档结构 +- `/openapi.json` 适合导入到其他工具里使用 +- `/dashboard/ui` 更适合做中文演示和业务讲解 + +如果某个路由返回的数据不符合预期,不要只盯着响应体猜。保留浏览器页面,同时回到 workflow 路径里调试会更快。对这个 Demo 来说,最快的方式仍然是: + +1. 先确定是哪一个请求载荷导致了错误结果 +1. 在 `ClassifyIntentStep`、`ScoreUrgencyStep` 和 `DecideRouteStep` 上打断点 +1. 用同一个请求重新跑一遍,观察哪一步开始偏离预期 + +推荐的 API 演示顺序: + +1. 调用 `POST /demo/reset-and-run`,加载演示数据并生成分诊结果 +1. 调用 `GET /dashboard`,查看整体摘要指标 +1. 调用 `GET /queues`,查看队列分布 +1. 调用 `GET /tickets`,查看全部分诊结果 +1. 调用 `GET /tickets/high-priority`,查看高优先级工单 +1. 调用 `GET /tickets/T-1001`,查看一个具体工单的持久化快照 + +## 测试 + +在 `apps` 目录下执行: + +```bash +pytest tests/ticket_triage/test_workflow.py tests/ticket_triage/test_service.py tests/ticket_triage/test_routing_rules.py tests/ticket_triage/test_api.py +``` + +## 相关文件 + +- 示例运行入口:`../../../../../../examples/run_ticket_triage.py` +- 规划入口文档:`../../../../../../docs/customer-support-ticket-triage-plan.md` + +如果这个应用继续演进,优先更新这份 README,再去同步相关的规划文档。 diff --git a/apps/src/sage/apps/ticket_triage/__init__.py b/apps/src/sage/apps/ticket_triage/__init__.py new file mode 100644 index 0000000..4a51520 --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/__init__.py @@ -0,0 +1,39 @@ +"""Customer support ticket triage MVP built on top of SAGE stateful workflows.""" + +from .demo_data import ( + build_demo_historical_resolutions, + build_demo_knowledge_articles, + build_demo_summary, + build_demo_tickets, +) +from .models import ( + HistoricalResolution, + KnowledgeBaseArticle, + TeamQueueSummary, + TicketEvent, + TicketStatusSnapshot, + TicketTriageResult, + TicketTriageRunResult, +) +from .service import ( + TicketTriageApplicationService, + create_demo_application_service, + create_fastapi_app, +) + +__all__ = [ + "HistoricalResolution", + "KnowledgeBaseArticle", + "TeamQueueSummary", + "TicketEvent", + "TicketStatusSnapshot", + "TicketTriageApplicationService", + "TicketTriageResult", + "TicketTriageRunResult", + "build_demo_historical_resolutions", + "build_demo_knowledge_articles", + "build_demo_summary", + "build_demo_tickets", + "create_demo_application_service", + "create_fastapi_app", +] diff --git a/apps/src/sage/apps/ticket_triage/demo_data.py b/apps/src/sage/apps/ticket_triage/demo_data.py new file mode 100644 index 0000000..5779555 --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/demo_data.py @@ -0,0 +1,204 @@ +"""Demo data for the customer support ticket triage MVP.""" + +from __future__ import annotations + +from .models import HistoricalResolution, KnowledgeBaseArticle, TicketEvent + + +def build_demo_tickets() -> list[TicketEvent]: + return [ + TicketEvent( + ticket_id="T-1001", + channel="email", + customer_id="CUST-001", + subject="账号无法登录", + message="我今天早上开始无法登录系统,提示账号锁定,请帮我处理。", + created_at="2026-04-20T09:00:00", + attachments=["login-error-screenshot.png"], + customer_tier="standard", + ), + TicketEvent( + ticket_id="T-1002", + channel="form", + customer_id="CUST-002", + subject="申请退款", + message="订单已经取消,但仍然扣费,请帮我退款。", + created_at="2026-04-20T09:05:00", + customer_tier="standard", + ), + TicketEvent( + ticket_id="T-1003", + channel="chat", + customer_id="CUST-003", + subject="订单还没到", + message="物流显示卡住了,我想知道订单为什么延迟。", + created_at="2026-04-20T09:08:00", + customer_tier="enterprise", + ), + TicketEvent( + ticket_id="T-1004", + channel="email", + customer_id="CUST-004", + subject="电子发票怎么下载", + message="请问电子发票入口在哪里,我需要下载报销。", + created_at="2026-04-20T09:12:00", + customer_tier="standard", + ), + TicketEvent( + ticket_id="T-1005", + channel="chat", + customer_id="CUST-005", + subject="系统报错 500", + message="提交工单时页面报 500 错误,附件里有报错截图。", + created_at="2026-04-20T09:16:00", + attachments=["500-error-screenshot.png"], + customer_tier="vip", + ), + TicketEvent( + ticket_id="T-1006", + channel="email", + customer_id="CUST-006", + subject="投诉升级", + message="我已经等待两天了还没人处理,请立即升级给主管。", + created_at="2026-04-20T09:21:00", + customer_tier="vip", + ), + TicketEvent( + ticket_id="T-1007", + channel="chat", + customer_id="CUST-001", + subject="还是无法登录", + message="刚才的账号锁定还没恢复,我现在还是无法登录,麻烦尽快处理。", + created_at="2026-04-20T09:28:00", + attachments=["second-login-error.png"], + customer_tier="standard", + ), + TicketEvent( + ticket_id="T-1008", + channel="form", + customer_id="CUST-007", + subject="重复扣费", + message="系统显示支付失败,但是银行卡被重复扣费两次。", + created_at="2026-04-20T09:35:00", + customer_tier="vip", + ), + TicketEvent( + ticket_id="T-1009", + channel="email", + customer_id="CUST-008", + subject="想问一下套餐功能", + message="想了解专业版套餐包含哪些功能,是否支持导出。", + created_at="2026-04-20T09:40:00", + customer_tier="standard", + ), + TicketEvent( + ticket_id="T-1010", + channel="chat", + customer_id="CUST-009", + subject="物流更新太慢", + message="订单一直没有新的物流信息,我需要今天确认能否送达。", + created_at="2026-04-20T09:45:00", + customer_tier="enterprise", + ), + ] + + +def build_demo_knowledge_articles() -> list[KnowledgeBaseArticle]: + return [ + KnowledgeBaseArticle( + article_id="KB-LOGIN-001", + title="账号锁定处理指南", + intent="login_issue", + keywords=["无法登录", "账号锁定", "登录失败", "密码错误"], + answer_template="您好,账号锁定通常可通过重置密码和二次验证解除,我们已为您附上自助恢复入口。", + assigned_team="technical_support", + ), + KnowledgeBaseArticle( + article_id="KB-REFUND-001", + title="退款到账与重复扣费说明", + intent="refund_request", + keywords=["退款", "重复扣费", "取消订单", "支付失败"], + answer_template="您好,退款工单已进入财务审核队列,重复扣费场景会优先核验交易流水并原路退回。", + assigned_team="billing_ops", + ), + KnowledgeBaseArticle( + article_id="KB-INVOICE-001", + title="电子发票下载入口", + intent="invoice_issue", + keywords=["发票", "电子发票", "下载", "报销"], + answer_template="您好,您可以在订单详情页的发票中心下载电子发票,如仍无法查看,请回复订单号。", + assigned_team="billing_ops", + ), + KnowledgeBaseArticle( + article_id="KB-ORDER-001", + title="物流延迟查询与补偿政策", + intent="order_delay", + keywords=["物流", "延迟", "订单", "送达"], + answer_template="您好,我们已收到您的物流延迟反馈,可先通过订单页查看最新轨迹,若超出承诺时效将补偿积分。", + assigned_team="order_ops", + ), + KnowledgeBaseArticle( + article_id="KB-GENERAL-001", + title="套餐功能说明", + intent="general_inquiry", + keywords=["套餐", "功能", "导出", "专业版"], + answer_template="您好,专业版支持导出、权限管理和自动报表,详细能力已为您附在回复中。", + assigned_team="l1_support", + ), + ] + + +def build_demo_historical_resolutions() -> list[HistoricalResolution]: + return [ + HistoricalResolution( + case_id="CASE-LOGIN-007", + title="企业用户账号锁定恢复", + intent="login_issue", + keywords=["账号锁定", "无法登录", "二次验证"], + resolution_template="历史案例显示,此类问题通常由连续输错密码触发,建议先执行密码重置,再检查验证码设备状态。", + assigned_team="technical_support", + ), + HistoricalResolution( + case_id="CASE-REFUND-003", + title="重复扣费人工退款处理", + intent="refund_request", + keywords=["重复扣费", "退款", "交易流水"], + resolution_template="历史上该问题通常由支付网关重试引起,需要财务核对流水并在 1 个工作日内原路退回。", + assigned_team="billing_ops", + ), + HistoricalResolution( + case_id="CASE-ORDER-011", + title="物流延迟升级协调", + intent="order_delay", + keywords=["物流", "延迟", "今天送达", "催办"], + resolution_template="相似案例一般会同步物流承运商并由订单团队人工催办,高价值客户会附带补偿说明。", + assigned_team="order_ops", + ), + HistoricalResolution( + case_id="CASE-TECH-002", + title="500 报错排查流程", + intent="technical_support", + keywords=["500", "报错", "截图", "提交失败"], + resolution_template="同类问题通常需要先收集报错截图、请求时间和账号信息,再交由技术支持排查服务日志。", + assigned_team="technical_support", + ), + HistoricalResolution( + case_id="CASE-COMPLAINT-004", + title="主管升级客诉处理", + intent="complaint_escalation", + keywords=["投诉", "升级", "立即处理", "等待两天"], + resolution_template="类似客诉应直接升级给当班主管,并在 30 分钟内给出人工回访。", + assigned_team="duty_manager", + ), + ] + + +def build_demo_summary() -> dict[str, int]: + tickets = build_demo_tickets() + return { + "ticket_count": len(tickets), + "channel_count": len({ticket.channel for ticket in tickets}), + "customer_count": len({ticket.customer_id for ticket in tickets}), + "knowledge_article_count": len(build_demo_knowledge_articles()), + "historical_case_count": len(build_demo_historical_resolutions()), + } diff --git a/apps/src/sage/apps/ticket_triage/models.py b/apps/src/sage/apps/ticket_triage/models.py new file mode 100644 index 0000000..1cbab50 --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/models.py @@ -0,0 +1,271 @@ +"""Structured models for the customer support ticket triage MVP.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, TypeAlias + + +@dataclass(frozen=True) +class TicketEvent: + ticket_id: str + channel: str + customer_id: str + subject: str + message: str + created_at: str + attachments: list[str] = field(default_factory=list) + language: str = "zh" + customer_tier: str = "standard" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TicketEvent: + return cls( + ticket_id=str(data["ticket_id"]), + channel=str(data.get("channel", "email")), + customer_id=str(data["customer_id"]), + subject=str(data.get("subject", "")), + message=str(data.get("message", "")), + created_at=str(data["created_at"]), + attachments=[str(item) for item in data.get("attachments", [])], + language=str(data.get("language", "zh")), + customer_tier=str(data.get("customer_tier", "standard")), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +TicketPayload: TypeAlias = TicketEvent | dict[str, Any] + + +def coerce_ticket_event(data: TicketPayload) -> TicketEvent: + if isinstance(data, TicketEvent): + return data + return TicketEvent.from_dict(data) + + +@dataclass(frozen=True) +class KnowledgeBaseArticle: + article_id: str + title: str + intent: str + keywords: list[str] + answer_template: str + assigned_team: str = "l1_support" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> KnowledgeBaseArticle: + return cls( + article_id=str(data["article_id"]), + title=str(data["title"]), + intent=str(data["intent"]), + keywords=[str(item) for item in data.get("keywords", [])], + answer_template=str(data["answer_template"]), + assigned_team=str(data.get("assigned_team", "l1_support")), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class HistoricalResolution: + case_id: str + title: str + intent: str + keywords: list[str] + resolution_template: str + assigned_team: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> HistoricalResolution: + return cls( + case_id=str(data["case_id"]), + title=str(data["title"]), + intent=str(data["intent"]), + keywords=[str(item) for item in data.get("keywords", [])], + resolution_template=str(data["resolution_template"]), + assigned_team=str(data["assigned_team"]), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class NormalizedTicket: + ticket_id: str + channel: str + customer_id: str + subject: str + message: str + created_at: str + language: str + customer_tier: str + attachments: list[str] + normalized_text: str + + @classmethod + def from_ticket(cls, ticket: TicketEvent, normalized_text: str) -> NormalizedTicket: + return cls( + ticket_id=ticket.ticket_id, + channel=ticket.channel, + customer_id=ticket.customer_id, + subject=ticket.subject, + message=ticket.message, + created_at=ticket.created_at, + language=ticket.language, + customer_tier=ticket.customer_tier, + attachments=list(ticket.attachments), + normalized_text=normalized_text, + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class IntentClassification: + intent: str + confidence: float + matched_keywords: list[str] + explanation: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class UrgencyScore: + score: int + priority: str + reasons: list[str] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class SimilarCaseMatch: + source_type: str + reference_id: str + title: str + relevance_score: float + suggested_team: str | None = None + suggested_reply: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class RoutingDecision: + assigned_team: str + action: str + auto_reply: bool + escalation_required: bool + reason: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class ReplyDraft: + subject: str + body: str + source: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class TicketTriageResult: + ticket_id: str + intent: str + priority: str + urgency_score: int + assigned_team: str + recommended_action: str + auto_reply: bool + reply_draft: str + reason_trace: list[str] + matched_reference_ids: list[str] + created_at: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TicketTriageResult: + return cls( + ticket_id=str(data["ticket_id"]), + intent=str(data["intent"]), + priority=str(data["priority"]), + urgency_score=int(data["urgency_score"]), + assigned_team=str(data["assigned_team"]), + recommended_action=str(data["recommended_action"]), + auto_reply=bool(data.get("auto_reply", False)), + reply_draft=str(data.get("reply_draft", "")), + reason_trace=[str(item) for item in data.get("reason_trace", [])], + matched_reference_ids=[str(item) for item in data.get("matched_reference_ids", [])], + created_at=str(data["created_at"]), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class TicketStatusSnapshot: + ticket_id: str + customer_id: str + channel: str + status: str + intent: str + priority: str + assigned_team: str + recommended_action: str + auto_reply: bool + last_updated: str + reason_trace: list[str] + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TicketStatusSnapshot: + return cls( + ticket_id=str(data["ticket_id"]), + customer_id=str(data["customer_id"]), + channel=str(data["channel"]), + status=str(data["status"]), + intent=str(data["intent"]), + priority=str(data["priority"]), + assigned_team=str(data["assigned_team"]), + recommended_action=str(data["recommended_action"]), + auto_reply=bool(data.get("auto_reply", False)), + last_updated=str(data["last_updated"]), + reason_trace=[str(item) for item in data.get("reason_trace", [])], + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class TeamQueueSummary: + team_name: str + open_ticket_count: int + high_priority_count: int + auto_reply_candidate_count: int + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class TicketTriageRunResult: + processed_event_count: int + high_priority_count: int + triage_results: list[TicketTriageResult] + queue_summaries: list[TeamQueueSummary] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/apps/src/sage/apps/ticket_triage/operators.py b/apps/src/sage/apps/ticket_triage/operators.py new file mode 100644 index 0000000..f72f6c8 --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/operators.py @@ -0,0 +1,347 @@ +"""SAGE operators for the customer support ticket triage MVP.""" + +from __future__ import annotations + +from typing import Any + +from sage.foundation import BatchFunction, MapFunction, SinkFunction + +from .models import ( + IntentClassification, + NormalizedTicket, + ReplyDraft, + RoutingDecision, + SimilarCaseMatch, + TicketEvent, + TicketStatusSnapshot, + TicketTriageResult, + UrgencyScore, + coerce_ticket_event, +) + +STATE_SERVICE_NAME = "ticket_triage_state" + +INTENT_KEYWORDS: dict[str, list[str]] = { + "login_issue": ["无法登录", "账号锁定", "登录失败", "密码错误"], + "refund_request": ["退款", "重复扣费", "扣费", "支付失败"], + "invoice_issue": ["发票", "电子发票", "报销"], + "order_delay": ["物流", "延迟", "送达", "订单"], + "technical_support": ["报错", "500", "失败", "截图"], + "complaint_escalation": ["投诉", "升级", "立即处理", "等待两天", "催办"], + "general_inquiry": ["套餐", "功能", "导出", "专业版"], +} + + +def _normalize_text(ticket: TicketEvent) -> str: + return f"{ticket.subject} {ticket.message}".strip().lower() + + +def _priority_rank(priority: str) -> int: + return {"low": 1, "medium": 2, "high": 3, "critical": 4}.get(priority, 1) + + +class DemoTicketSource(BatchFunction): + """BatchFunction source that emits supplied tickets one by one.""" + + def __init__(self, events: list[Any], **kwargs) -> None: + super().__init__(**kwargs) + self.events = list(events) + self.index = 0 + + def execute(self) -> Any | None: + if self.index >= len(self.events): + return None + event = self.events[self.index] + self.index += 1 + return event + + +class NormalizeTicketStep(MapFunction): + def execute(self, data: TicketEvent | dict[str, Any]) -> dict[str, Any]: + ticket = coerce_ticket_event(data) + normalized_ticket = NormalizedTicket.from_ticket( + ticket, normalized_text=_normalize_text(ticket) + ) + return { + "ticket": ticket, + "normalized_ticket": normalized_ticket, + "reason_trace": [f"渠道={ticket.channel}", f"客户等级={ticket.customer_tier}"], + } + + +class EnrichCustomerContextStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + ticket: TicketEvent = data["ticket"] + recent_tickets = self.call_service( + STATE_SERVICE_NAME, + ticket.customer_id, + method="list_customer_recent_tickets", + ) + return { + **data, + "recent_tickets": recent_tickets, + } + + +class ClassifyIntentStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + normalized_ticket: NormalizedTicket = data["normalized_ticket"] + recent_tickets: list[TicketStatusSnapshot] = data["recent_tickets"] + text = normalized_ticket.normalized_text + + complaint_keywords = [ + keyword + for keyword in INTENT_KEYWORDS["complaint_escalation"] + if keyword.lower() in text + ] + if complaint_keywords and recent_tickets: + classification = IntentClassification( + intent="complaint_escalation", + confidence=0.9, + matched_keywords=complaint_keywords, + explanation=("命中升级类关键词,且该客户已有历史工单,按投诉升级优先处理。"), + ) + return { + **data, + "classification": classification, + } + + best_intent = "general_inquiry" + best_keywords: list[str] = [] + for intent, keywords in INTENT_KEYWORDS.items(): + matched = [keyword for keyword in keywords if keyword.lower() in text] + if len(matched) > len(best_keywords): + best_intent = intent + best_keywords = matched + + confidence = 0.45 + min(0.45, 0.12 * len(best_keywords)) + if not best_keywords and best_intent == "general_inquiry": + explanation = "未命中强规则,回退为通用咨询。" + else: + explanation = f"命中关键词: {', '.join(best_keywords)}" + classification = IntentClassification( + intent=best_intent, + confidence=round(confidence, 2), + matched_keywords=best_keywords, + explanation=explanation, + ) + return { + **data, + "classification": classification, + } + + +class ScoreUrgencyStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + ticket: TicketEvent = data["ticket"] + normalized_ticket: NormalizedTicket = data["normalized_ticket"] + classification: IntentClassification = data["classification"] + recent_tickets: list[TicketStatusSnapshot] = data["recent_tickets"] + + score = 25 + reasons = ["默认初始分=25"] + text = normalized_ticket.normalized_text + + if classification.intent in {"login_issue", "technical_support"}: + score += 20 + reasons.append("故障类问题,加 20 分") + if classification.intent == "order_delay": + score += 10 + reasons.append("履约延迟场景,加 10 分") + if classification.intent in {"refund_request", "complaint_escalation"}: + score += 25 + reasons.append("资金或投诉场景,加 25 分") + if any(keyword in text for keyword in ["立即处理", "等待两天", "尽快", "催办"]): + score += 20 + reasons.append("命中紧急关键词,加 20 分") + if any(keyword in text for keyword in ["账号锁定", "扣费", "支付失败", "500", "投诉升级"]): + score += 10 + reasons.append("命中高风险信号,加 10 分") + if recent_tickets and any(item.intent == classification.intent for item in recent_tickets): + score += 15 + reasons.append("同客户重复报障,加 15 分") + if ticket.customer_tier in {"vip", "enterprise"}: + score += 10 + reasons.append("高价值客户,加 10 分") + if ticket.attachments: + score += 10 + reasons.append("带附件需人工复核,加 10 分") + if ticket.channel == "chat": + score += 5 + reasons.append("在线聊天要求即时响应,加 5 分") + + if score >= 85: + priority = "critical" + elif score >= 65: + priority = "high" + elif score >= 45: + priority = "medium" + else: + priority = "low" + + urgency = UrgencyScore(score=score, priority=priority, reasons=reasons) + return { + **data, + "urgency": urgency, + } + + +class RecallSimilarCasesStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + normalized_ticket: NormalizedTicket = data["normalized_ticket"] + classification: IntentClassification = data["classification"] + + knowledge_matches = self.call_service( + STATE_SERVICE_NAME, + classification.intent, + normalized_ticket.normalized_text, + method="search_knowledge_articles", + ) + historical_matches = self.call_service( + STATE_SERVICE_NAME, + classification.intent, + normalized_ticket.normalized_text, + method="search_similar_resolutions", + ) + combined_matches = sorted( + [*knowledge_matches, *historical_matches], + key=lambda item: item.relevance_score, + reverse=True, + ) + return { + **data, + "knowledge_matches": knowledge_matches, + "historical_matches": historical_matches, + "combined_matches": combined_matches, + } + + +class DecideRouteStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + classification: IntentClassification = data["classification"] + urgency: UrgencyScore = data["urgency"] + knowledge_matches: list[SimilarCaseMatch] = data["knowledge_matches"] + + assigned_team = "l1_support" + action = "manual_triage" + escalation_required = False + + if classification.intent == "complaint_escalation" or urgency.priority == "critical": + assigned_team = "duty_manager" + action = "supervisor_escalation" + escalation_required = True + elif classification.intent in {"refund_request", "invoice_issue"}: + assigned_team = "billing_ops" + action = "billing_review" + elif classification.intent == "order_delay": + assigned_team = "order_ops" + action = "order_follow_up" + elif classification.intent in {"login_issue", "technical_support"}: + assigned_team = "technical_support" + action = "technical_triage" + + auto_reply = bool( + knowledge_matches + and knowledge_matches[0].relevance_score >= 0.7 + and urgency.priority in {"low", "medium"} + and not escalation_required + ) + if auto_reply: + action = "send_auto_reply" + reason = "命中高相关 FAQ 且优先级不高,先自动回复。" + elif escalation_required: + reason = "工单涉及投诉升级或紧急度极高,直接转主管队列。" + else: + reason = f"根据意图 {classification.intent} 和优先级 {urgency.priority} 路由到 {assigned_team}。" + + routing = RoutingDecision( + assigned_team=assigned_team, + action=action, + auto_reply=auto_reply, + escalation_required=escalation_required, + reason=reason, + ) + return { + **data, + "routing": routing, + } + + +class DraftReplyStep(MapFunction): + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + ticket: TicketEvent = data["ticket"] + routing: RoutingDecision = data["routing"] + matches: list[SimilarCaseMatch] = data["combined_matches"] + + if routing.auto_reply and matches: + top_match = matches[0] + reply = ReplyDraft( + subject=f"关于工单 {ticket.ticket_id} 的处理建议", + body=top_match.suggested_reply or "您好,我们已收到您的问题并正在处理。", + source=top_match.source_type, + ) + else: + reply = ReplyDraft( + subject="", + body="", + source="manual", + ) + return { + **data, + "reply": reply, + } + + +class PersistTicketStateStep(MapFunction): + def execute(self, data: dict[str, Any]) -> TicketTriageResult: + ticket: TicketEvent = data["ticket"] + classification: IntentClassification = data["classification"] + urgency: UrgencyScore = data["urgency"] + routing: RoutingDecision = data["routing"] + reply: ReplyDraft = data["reply"] + matches: list[SimilarCaseMatch] = data["combined_matches"] + + reason_trace = list(data["reason_trace"]) + reason_trace.append(classification.explanation) + reason_trace.extend(urgency.reasons) + reason_trace.append(routing.reason) + + result = TicketTriageResult( + ticket_id=ticket.ticket_id, + intent=classification.intent, + priority=urgency.priority, + urgency_score=urgency.score, + assigned_team=routing.assigned_team, + recommended_action=routing.action, + auto_reply=routing.auto_reply, + reply_draft=reply.body, + reason_trace=reason_trace, + matched_reference_ids=[item.reference_id for item in matches[:3]], + created_at=ticket.created_at, + ) + snapshot = TicketStatusSnapshot( + ticket_id=ticket.ticket_id, + customer_id=ticket.customer_id, + channel=ticket.channel, + status="open", + intent=classification.intent, + priority=urgency.priority, + assigned_team=routing.assigned_team, + recommended_action=routing.action, + auto_reply=routing.auto_reply, + last_updated=ticket.created_at, + reason_trace=reason_trace, + ) + self.call_service(STATE_SERVICE_NAME, snapshot, method="save_ticket_snapshot") + self.call_service(STATE_SERVICE_NAME, result, method="append_triage_result") + self.call_service(STATE_SERVICE_NAME, result, method="assign_team_queue") + return result + + +class ResultCollectorSink(SinkFunction): + def __init__(self, results: list[TicketTriageResult], **kwargs) -> None: + super().__init__(**kwargs) + self.results = results + + def execute(self, data: TicketTriageResult) -> None: + self.results.append(data) diff --git a/apps/src/sage/apps/ticket_triage/service.py b/apps/src/sage/apps/ticket_triage/service.py new file mode 100644 index 0000000..4abd7e3 --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/service.py @@ -0,0 +1,205 @@ +"""Service facade and optional FastAPI adapter for the ticket triage MVP.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any + +try: + from fastapi import FastAPI, HTTPException + from fastapi.responses import HTMLResponse +except ImportError: # pragma: no cover - optional dependency for HTTP adapter only + FastAPI = None + HTTPException = None + HTMLResponse = None + +from .demo_data import ( + build_demo_historical_resolutions, + build_demo_knowledge_articles, + build_demo_summary, + build_demo_tickets, +) +from .models import TicketEvent, TicketStatusSnapshot, TicketTriageRunResult, coerce_ticket_event +from .state_store import InMemoryTicketTriageStateStore +from .ui import render_dashboard_page +from .workflow import TicketTriageWorkflowRunner + + +def _to_payload(value: Any) -> Any: + if hasattr(value, "to_dict"): + return value.to_dict() + if is_dataclass(value): + return asdict(value) + if isinstance(value, list): + return [_to_payload(item) for item in value] + if isinstance(value, dict): + return {key: _to_payload(item) for key, item in value.items()} + return value + + +class TicketTriageApplicationService: + """High-level service interface for demo ingest and queue queries.""" + + def __init__(self, storage_path: str | Path | None = None) -> None: + self.state_store = InMemoryTicketTriageStateStore(storage_path=storage_path) + self.workflow = TicketTriageWorkflowRunner(self.state_store) + self._ensure_reference_data() + + def _ensure_reference_data(self, *, force: bool = False) -> None: + if force or not self.state_store.has_reference_data(): + self.state_store.load_reference_data( + build_demo_knowledge_articles(), + build_demo_historical_resolutions(), + ) + + def reset(self) -> None: + self.state_store.reset() + self._ensure_reference_data(force=True) + + def ingest_tickets(self, events: list[TicketEvent | dict[str, Any]]) -> TicketTriageRunResult: + self._ensure_reference_data() + resolved_events = [coerce_ticket_event(item) for item in events] + triage_results = self.workflow.ingest_tickets(resolved_events) + queue_summaries = self.state_store.list_queue_summary() + high_priority_count = sum( + 1 for item in triage_results if item.priority in {"high", "critical"} + ) + return TicketTriageRunResult( + processed_event_count=len(resolved_events), + high_priority_count=high_priority_count, + triage_results=triage_results, + queue_summaries=queue_summaries, + ) + + def run_demo(self, *, reset: bool = True) -> TicketTriageRunResult: + if reset: + self.reset() + return self.ingest_tickets(build_demo_tickets()) + + def get_ticket(self, ticket_id: str) -> TicketStatusSnapshot: + snapshot = self.state_store.get_ticket_snapshot(ticket_id) + if snapshot is None: + raise KeyError(f"No ticket found for '{ticket_id}'.") + return snapshot + + def list_queue_summary(self): + return self.state_store.list_queue_summary() + + def list_open_high_priority_tickets(self): + return self.state_store.list_open_high_priority_tickets() + + def list_triage_results(self): + return self.state_store.list_triage_results() + + def get_dashboard_summary(self) -> dict[str, Any]: + triage_results = self.list_triage_results() + queue_summaries = self.list_queue_summary() + high_priority_count = sum( + 1 for item in triage_results if item.priority in {"high", "critical"} + ) + auto_reply_count = sum(1 for item in triage_results if item.auto_reply) + escalated_count = sum( + 1 + for item in triage_results + if item.assigned_team == "duty_manager" + or item.recommended_action == "supervisor_escalation" + ) + intent_distribution: dict[str, int] = {} + for item in triage_results: + intent_distribution[item.intent] = intent_distribution.get(item.intent, 0) + 1 + busiest_queue = queue_summaries[0].team_name if queue_summaries else None + latest_ticket_id = triage_results[0].ticket_id if triage_results else None + return { + "total_ticket_count": len(triage_results), + "high_priority_count": high_priority_count, + "auto_reply_count": auto_reply_count, + "escalated_count": escalated_count, + "queue_count": len(queue_summaries), + "busiest_queue": busiest_queue, + "latest_ticket_id": latest_ticket_id, + "intent_distribution": intent_distribution, + } + + def get_demo_summary(self) -> dict[str, int]: + return build_demo_summary() + + +def create_demo_application_service( + storage_path: str | Path | None = None, +) -> TicketTriageApplicationService: + return TicketTriageApplicationService(storage_path=storage_path) + + +def create_fastapi_app( + service: TicketTriageApplicationService | None = None, +) -> FastAPI: + if FastAPI is None or HTTPException is None or HTMLResponse is None: + raise RuntimeError( + "FastAPI is not installed. Install fastapi to use the ticket triage API." + ) + + runtime_service = service or TicketTriageApplicationService() + app = FastAPI(title="SAGE Ticket Triage MVP", version="0.1.0") + + @app.get("/") + def root() -> dict[str, Any]: + return { + "name": "SAGE Ticket Triage MVP", + "version": "0.1.0", + "routes": [ + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/dashboard", + "/dashboard/ui", + "/tickets", + "/tickets/ingest", + "/tickets/high-priority", + "/tickets/{ticket_id}", + "/queues", + "/demo/reset-and-run", + ], + } + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/dashboard") + def get_dashboard() -> dict[str, Any]: + return _to_payload(runtime_service.get_dashboard_summary()) + + @app.get("/dashboard/ui", response_class=HTMLResponse) + def get_dashboard_ui() -> HTMLResponse: + return HTMLResponse(render_dashboard_page()) + + @app.get("/tickets") + def list_tickets() -> list[dict[str, Any]]: + return _to_payload(runtime_service.list_triage_results()) + + @app.post("/tickets/ingest") + def ingest_tickets(payload: list[dict[str, Any]]) -> dict[str, Any]: + return _to_payload(runtime_service.ingest_tickets(payload)) + + @app.get("/tickets/high-priority") + def list_high_priority_tickets() -> list[dict[str, Any]]: + return _to_payload(runtime_service.list_open_high_priority_tickets()) + + @app.get("/tickets/{ticket_id}") + def get_ticket(ticket_id: str) -> dict[str, Any]: + try: + return _to_payload(runtime_service.get_ticket(ticket_id)) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.get("/queues") + def list_queue_summary() -> list[dict[str, Any]]: + return _to_payload(runtime_service.list_queue_summary()) + + @app.post("/demo/reset-and-run") + def run_demo() -> dict[str, Any]: + return _to_payload(runtime_service.run_demo(reset=True)) + + return app diff --git a/apps/src/sage/apps/ticket_triage/state_store.py b/apps/src/sage/apps/ticket_triage/state_store.py new file mode 100644 index 0000000..ae7bdfa --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/state_store.py @@ -0,0 +1,217 @@ +"""Explicit in-memory state store for the ticket triage MVP.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from .models import ( + HistoricalResolution, + KnowledgeBaseArticle, + SimilarCaseMatch, + TeamQueueSummary, + TicketStatusSnapshot, + TicketTriageResult, +) + + +def parse_timestamp(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +class InMemoryTicketTriageStateStore: + """Pluggable in-memory state store with optional JSON persistence.""" + + def __init__(self, storage_path: str | Path | None = None) -> None: + self.storage_path = None if storage_path is None else Path(storage_path) + self.knowledge_articles: dict[str, KnowledgeBaseArticle] = {} + self.historical_resolutions: dict[str, HistoricalResolution] = {} + self.tickets: dict[str, TicketStatusSnapshot] = {} + self.triage_results: dict[str, TicketTriageResult] = {} + + if self.storage_path is not None and self.storage_path.exists(): + self._load() + + def has_reference_data(self) -> bool: + return bool(self.knowledge_articles) and bool(self.historical_resolutions) + + def load_reference_data( + self, + knowledge_articles: list[KnowledgeBaseArticle], + historical_resolutions: list[HistoricalResolution], + ) -> None: + self.knowledge_articles = {item.article_id: item for item in knowledge_articles} + self.historical_resolutions = {item.case_id: item for item in historical_resolutions} + self._persist() + + def reset(self) -> None: + self.knowledge_articles = {} + self.historical_resolutions = {} + self.tickets = {} + self.triage_results = {} + self._persist() + + def save_ticket_snapshot(self, snapshot: TicketStatusSnapshot) -> None: + self.tickets[snapshot.ticket_id] = snapshot + self._persist() + + def get_ticket_snapshot(self, ticket_id: str) -> TicketStatusSnapshot | None: + return self.tickets.get(ticket_id) + + def build_status_snapshot(self, ticket_id: str) -> TicketStatusSnapshot | None: + return self.get_ticket_snapshot(ticket_id) + + def list_customer_recent_tickets( + self, + customer_id: str, + limit: int | None = 5, + ) -> list[TicketStatusSnapshot]: + items = [item for item in self.tickets.values() if item.customer_id == customer_id] + items.sort(key=lambda item: item.last_updated, reverse=True) + return items if limit is None else items[:limit] + + def search_knowledge_articles( + self, + intent: str, + normalized_text: str, + limit: int | None = 3, + ) -> list[SimilarCaseMatch]: + matches: list[SimilarCaseMatch] = [] + for article in self.knowledge_articles.values(): + matched = [kw for kw in article.keywords if kw.lower() in normalized_text] + base_score = 0.58 if article.intent == intent else 0.18 if matched else 0.0 + if base_score == 0.0: + continue + relevance_score = min(0.98, base_score + 0.1 * len(matched)) + matches.append( + SimilarCaseMatch( + source_type="faq", + reference_id=article.article_id, + title=article.title, + relevance_score=round(relevance_score, 2), + suggested_team=article.assigned_team, + suggested_reply=article.answer_template, + ) + ) + matches.sort(key=lambda item: item.relevance_score, reverse=True) + return matches if limit is None else matches[:limit] + + def search_similar_resolutions( + self, + intent: str, + normalized_text: str, + limit: int | None = 3, + ) -> list[SimilarCaseMatch]: + matches: list[SimilarCaseMatch] = [] + for case in self.historical_resolutions.values(): + matched = [kw for kw in case.keywords if kw.lower() in normalized_text] + base_score = 0.52 if case.intent == intent else 0.16 if matched else 0.0 + if base_score == 0.0: + continue + relevance_score = min(0.95, base_score + 0.08 * len(matched)) + matches.append( + SimilarCaseMatch( + source_type="historical_case", + reference_id=case.case_id, + title=case.title, + relevance_score=round(relevance_score, 2), + suggested_team=case.assigned_team, + suggested_reply=case.resolution_template, + ) + ) + matches.sort(key=lambda item: item.relevance_score, reverse=True) + return matches if limit is None else matches[:limit] + + def append_triage_result(self, result: TicketTriageResult) -> None: + self.triage_results[result.ticket_id] = result + self._persist() + + def assign_team_queue(self, result: TicketTriageResult) -> None: + self.triage_results[result.ticket_id] = result + self._persist() + + def list_queue_summary(self) -> list[TeamQueueSummary]: + queue_stats: dict[str, dict[str, int]] = {} + for snapshot in self.tickets.values(): + team_stats = queue_stats.setdefault( + snapshot.assigned_team, + { + "open_ticket_count": 0, + "high_priority_count": 0, + "auto_reply_candidate_count": 0, + }, + ) + team_stats["open_ticket_count"] += 1 + if snapshot.priority in {"high", "critical"}: + team_stats["high_priority_count"] += 1 + if snapshot.auto_reply: + team_stats["auto_reply_candidate_count"] += 1 + + summaries = [ + TeamQueueSummary( + team_name=team_name, + open_ticket_count=stats["open_ticket_count"], + high_priority_count=stats["high_priority_count"], + auto_reply_candidate_count=stats["auto_reply_candidate_count"], + ) + for team_name, stats in queue_stats.items() + ] + summaries.sort( + key=lambda item: (item.high_priority_count, item.open_ticket_count, item.team_name), + reverse=True, + ) + return summaries + + def list_open_high_priority_tickets(self) -> list[TicketStatusSnapshot]: + items = [item for item in self.tickets.values() if item.priority in {"high", "critical"}] + items.sort(key=lambda item: (item.priority, item.last_updated), reverse=True) + return items + + def list_triage_results(self) -> list[TicketTriageResult]: + items = list(self.triage_results.values()) + items.sort(key=lambda item: item.created_at, reverse=True) + return items + + def _persist(self) -> None: + if self.storage_path is None: + return + + if self.storage_path.parent and not self.storage_path.parent.exists(): + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + + payload = { + "knowledge_articles": [item.to_dict() for item in self.knowledge_articles.values()], + "historical_resolutions": [ + item.to_dict() for item in self.historical_resolutions.values() + ], + "tickets": [item.to_dict() for item in self.tickets.values()], + "triage_results": [item.to_dict() for item in self.triage_results.values()], + } + self.storage_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _load(self) -> None: + if self.storage_path is None: + return + + payload = json.loads(self.storage_path.read_text(encoding="utf-8")) + self.knowledge_articles = { + item["article_id"]: KnowledgeBaseArticle.from_dict(item) + for item in payload.get("knowledge_articles", []) + } + self.historical_resolutions = { + item["case_id"]: HistoricalResolution.from_dict(item) + for item in payload.get("historical_resolutions", []) + } + self.tickets = { + item["ticket_id"]: TicketStatusSnapshot.from_dict(item) + for item in payload.get("tickets", []) + } + self.triage_results = { + item["ticket_id"]: TicketTriageResult.from_dict(item) + for item in payload.get("triage_results", []) + } diff --git a/apps/src/sage/apps/ticket_triage/ui.py b/apps/src/sage/apps/ticket_triage/ui.py new file mode 100644 index 0000000..919bc2f --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/ui.py @@ -0,0 +1,819 @@ +"""Chinese web UI for the ticket triage dashboard.""" + +from __future__ import annotations + + +def render_dashboard_page() -> str: + return """ + + + + + SAGE 客服工单分诊看板 + + + +
    +
    +
    +

    SAGE Ticket Triage

    +

    客服工单分诊看板

    +

    + 这个界面把工单分诊的规则链路、队列分布、优先级结果和单票明细串到同一个中文页面里,适合做 Demo 演示、接口联调和值班排查。 +

    +
    + 显式 SAGE workflow + 状态化工单跟踪 + 中文可解释分诊 +
    +
    +
    +
    +
    +

    操作区

    +
    +

    先加载演示工单,再刷新看板;如果要验证自定义场景,可以直接在页面底部提交单条工单。

    +
    +
    + + + +
    +
    + +
    +
    等待操作。
    +
    +
    + +
    +
    + +
    +
    +

    看板摘要

    +
    +
    还没有加载数据。
    +
    + +
    +
    +

    队列分布

    +
    +
    +
    加载演示数据后,这里会显示各队列的工单负载。
    +
    +
    + +
    +
    +

    工单详情

    +
    +
    请输入工单编号,或点击下方结果列表中的工单编号。
    +
    + +
    +
    +

    最近分诊结果

    +
    +
    暂时没有分诊结果。
    +
    + +
    +
    +

    高优先级工单

    +
    +
    +
    暂无高优先级工单。
    +
    +
    + +
    +
    +
    +

    提交单条工单

    +

    可以直接在页面里模拟一条新工单,验证分类、优先级和队列路由结果。

    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + + + +""" diff --git a/apps/src/sage/apps/ticket_triage/workflow.py b/apps/src/sage/apps/ticket_triage/workflow.py new file mode 100644 index 0000000..6c12676 --- /dev/null +++ b/apps/src/sage/apps/ticket_triage/workflow.py @@ -0,0 +1,104 @@ +"""SAGE workflow orchestration for the ticket triage MVP.""" + +from __future__ import annotations + +from pathlib import Path + +from sage.runtime import BaseService, LocalEnvironment + +from .models import TicketEvent, TicketStatusSnapshot, TicketTriageResult +from .operators import ( + STATE_SERVICE_NAME, + ClassifyIntentStep, + DecideRouteStep, + DemoTicketSource, + DraftReplyStep, + EnrichCustomerContextStep, + NormalizeTicketStep, + PersistTicketStateStep, + RecallSimilarCasesStep, + ResultCollectorSink, + ScoreUrgencyStep, +) +from .state_store import InMemoryTicketTriageStateStore + + +class TicketTriageStateService(BaseService): + """Runtime service exposing explicit state operations to workflow nodes.""" + + def __init__(self, store: InMemoryTicketTriageStateStore) -> None: + super().__init__() + self.store = store + + def load_reference_data(self, knowledge_articles, historical_resolutions) -> None: + self.store.load_reference_data(knowledge_articles, historical_resolutions) + + def save_ticket_snapshot(self, snapshot) -> None: + self.store.save_ticket_snapshot(snapshot) + + def get_ticket_snapshot(self, ticket_id: str): + return self.store.get_ticket_snapshot(ticket_id) + + def build_status_snapshot(self, ticket_id: str): + return self.store.build_status_snapshot(ticket_id) + + def list_customer_recent_tickets(self, customer_id: str): + return self.store.list_customer_recent_tickets(customer_id) + + def search_knowledge_articles(self, intent: str, normalized_text: str): + return self.store.search_knowledge_articles(intent, normalized_text) + + def search_similar_resolutions(self, intent: str, normalized_text: str): + return self.store.search_similar_resolutions(intent, normalized_text) + + def append_triage_result(self, result) -> None: + self.store.append_triage_result(result) + + def assign_team_queue(self, result) -> None: + self.store.assign_team_queue(result) + + def list_queue_summary(self): + return self.store.list_queue_summary() + + def list_open_high_priority_tickets(self): + return self.store.list_open_high_priority_tickets() + + +class TicketTriageWorkflowRunner: + """Thin workflow runner that rebuilds a LocalEnvironment per ingest operation.""" + + def __init__(self, state_store: InMemoryTicketTriageStateStore | None = None) -> None: + self.state_store = state_store or InMemoryTicketTriageStateStore() + + @classmethod + def from_storage_path( + cls, storage_path: str | Path | None = None + ) -> TicketTriageWorkflowRunner: + return cls(InMemoryTicketTriageStateStore(storage_path=storage_path)) + + def _build_environment(self, name: str) -> LocalEnvironment: + environment = LocalEnvironment(name) + environment.set_console_log_level("ERROR") + environment.register_service(STATE_SERVICE_NAME, TicketTriageStateService, self.state_store) + return environment + + def ingest_tickets(self, events: list[TicketEvent | dict]) -> list[TicketTriageResult]: + results: list[TicketTriageResult] = [] + environment = self._build_environment("ticket_triage_ingest") + ( + environment.from_batch(DemoTicketSource, events=events) + .map(NormalizeTicketStep) + .map(EnrichCustomerContextStep) + .map(ClassifyIntentStep) + .map(ScoreUrgencyStep) + .map(RecallSimilarCasesStep) + .map(DecideRouteStep) + .map(DraftReplyStep) + .map(PersistTicketStateStep) + .sink(ResultCollectorSink, results=results) + ) + environment.submit(autostop=True) + return results + + def get_ticket(self, ticket_id: str) -> TicketStatusSnapshot | None: + return self.state_store.get_ticket_snapshot(ticket_id) diff --git a/apps/src/sage/apps/video/operators/__init__.py b/apps/src/sage/apps/video/operators/__init__.py index 30c9a18..df3af58 100644 --- a/apps/src/sage/apps/video/operators/__init__.py +++ b/apps/src/sage/apps/video/operators/__init__.py @@ -31,6 +31,7 @@ def __getattr__(name: str): globals()[name] = value return value + __all__ = [ "VideoFrameSource", "FramePreprocessor", diff --git a/apps/tests/patent_landscape_mapper/test_pipeline.py b/apps/tests/patent_landscape_mapper/test_pipeline.py index 8c930ac..e3bb017 100644 --- a/apps/tests/patent_landscape_mapper/test_pipeline.py +++ b/apps/tests/patent_landscape_mapper/test_pipeline.py @@ -47,4 +47,4 @@ def test_patent_landscape_console_renderer(capsys) -> None: captured = capsys.readouterr() assert "SAGE Patent Landscape Mapper" in captured.out - assert "Whitespace Opportunities" in captured.out \ No newline at end of file + assert "Whitespace Opportunities" in captured.out diff --git a/apps/tests/student_improvement/test_llm.py b/apps/tests/student_improvement/test_llm.py index 2f035f0..5f2b970 100644 --- a/apps/tests/student_improvement/test_llm.py +++ b/apps/tests/student_improvement/test_llm.py @@ -6,13 +6,13 @@ def test_llm_settings_from_env(monkeypatch) -> None: monkeypatch.setenv("SAGE_OPENAI_BASE_URL", "https://api.sage.org.ai/v1") - monkeypatch.setenv("SAGE_OPENAI_API_KEY", "test-key") + monkeypatch.setenv("SAGE_OPENAI_API_KEY", "test-key") # pragma: allowlist secret monkeypatch.setenv("SAGE_OPENAI_MODEL", "demo-model") settings = SageOpenAISettings.from_env() assert settings.base_url == "https://api.sage.org.ai/v1" - assert settings.api_key == "test-key" + assert settings.api_key == "test-key" # pragma: allowlist secret assert settings.model == "demo-model" assert settings.configured is True @@ -33,7 +33,7 @@ def test_llm_settings_from_dotenv(tmp_path, monkeypatch) -> None: monkeypatch.delenv("SAGE_OPENAI_MODEL", raising=False) (tmp_path / ".env").write_text( "SAGE_OPENAI_BASE_URL=https://api.sage.org.ai/v1\n" - "SAGE_OPENAI_API_KEY=dotenv-key\n" + "SAGE_OPENAI_API_KEY=dotenv-key\n" # pragma: allowlist secret "SAGE_OPENAI_MODEL=dotenv-model\n", encoding="utf-8", ) @@ -41,7 +41,7 @@ def test_llm_settings_from_dotenv(tmp_path, monkeypatch) -> None: settings = SageOpenAISettings.from_env() assert settings.base_url == "https://api.sage.org.ai/v1" - assert settings.api_key == "dotenv-key" + assert settings.api_key == "dotenv-key" # pragma: allowlist secret assert settings.model == "dotenv-model" assert settings.configured is True diff --git a/apps/tests/student_improvement/test_workflow.py b/apps/tests/student_improvement/test_workflow.py index a2a3838..2c35548 100644 --- a/apps/tests/student_improvement/test_workflow.py +++ b/apps/tests/student_improvement/test_workflow.py @@ -53,7 +53,9 @@ def test_wrong_question_bank_changes_dynamically(tmp_path) -> None: service.import_exam(exams[0]) service.import_exam(exams[1]) - wrong_bank = {item.question_id: item for item in service.get_wrong_question_bank(exams[0].student_id)} + wrong_bank = { + item.question_id: item for item in service.get_wrong_question_bank(exams[0].student_id) + } assert wrong_bank["ALG-EQ-01"].mastery_status == "reviewing" assert wrong_bank["ALG-FR-01"].mastery_status == "reviewing" @@ -71,7 +73,9 @@ def test_study_plan_structure_is_stable(tmp_path) -> None: assert len(result.diagnosis.study_plan) >= 3 assert all(step.title for step in result.diagnosis.study_plan) assert all(step.recommended_actions for step in result.diagnosis.study_plan) - assert all(isinstance(step.target_knowledge_points, list) for step in result.diagnosis.study_plan) + assert all( + isinstance(step.target_knowledge_points, list) for step in result.diagnosis.study_plan + ) def test_knowledge_graph_marks_mastery_status(tmp_path) -> None: diff --git a/apps/tests/supply_chain_alert/__init__.py b/apps/tests/supply_chain_alert/__init__.py new file mode 100644 index 0000000..3573177 --- /dev/null +++ b/apps/tests/supply_chain_alert/__init__.py @@ -0,0 +1 @@ +"""Tests for the supply chain alert dashboard app.""" diff --git a/apps/tests/supply_chain_alert/test_api.py b/apps/tests/supply_chain_alert/test_api.py new file mode 100644 index 0000000..f6ac8d9 --- /dev/null +++ b/apps/tests/supply_chain_alert/test_api.py @@ -0,0 +1,111 @@ +from fastapi.testclient import TestClient + +from sage.apps.supply_chain_alert import ( + GatewayExplanationStatus, + RiskExplanationResult, + SupplyChainAlertApplicationService, + create_fastapi_app, +) +from sage.apps.supply_chain_alert.demo_data import build_demo_supply_events + + +def test_api_health_and_root(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + client = TestClient(create_fastapi_app(service)) + + root_response = client.get("/") + health_response = client.get("/health") + + assert root_response.status_code == 200 + assert root_response.json()["name"] == "SAGE Supply Chain Alert Dashboard" + assert "/dashboard" in root_response.json()["routes"] + assert "/dashboard/ui" in root_response.json()["routes"] + assert health_response.status_code == 200 + assert health_response.json() == {"status": "ok"} + + +def test_api_dashboard_ui_route(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + client = TestClient(create_fastapi_app(service)) + + response = client.get("/dashboard/ui") + + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "供应链异常预警看板" in response.text + assert "加载演示数据" in response.text + assert "运营总控" in response.text + assert "采购协调" in response.text + assert "仓配调度" in response.text + assert "供应商管理" in response.text + assert "角色摘要" in response.text + assert "/alerts/explanations?max_alerts=3" in response.text + + +def test_api_demo_and_query_routes(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + client = TestClient(create_fastapi_app(service)) + + demo_response = client.post("/demo/reset-and-run") + dashboard_response = client.get("/dashboard") + alerts_response = client.get("/alerts/open") + suppliers_response = client.get("/suppliers/risk") + + assert demo_response.status_code == 200 + demo_payload = demo_response.json() + assert demo_payload["processed_event_count"] == 10 + assert demo_payload["alert_count"] >= 5 + + assert dashboard_response.status_code == 200 + assert dashboard_response.json()["open_alert_count"] == demo_payload["alert_count"] + + assert alerts_response.status_code == 200 + assert len(alerts_response.json()) == demo_payload["alert_count"] + + assert suppliers_response.status_code == 200 + assert suppliers_response.json() + assert suppliers_response.json()[0]["supplier_id"] == "SUP-ALPHA" + + +def test_api_ingest_events_route(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + client = TestClient(create_fastapi_app(service)) + + payload = [item.to_dict() for item in build_demo_supply_events()[:3]] + response = client.post("/events/ingest", json=payload) + + assert response.status_code == 200 + body = response.json() + assert body["processed_event_count"] == 3 + assert body["dashboard"]["open_alert_count"] >= 1 + + +class FakeRiskExplainer: + def explain_current_risks(self, *, dashboard, alerts, supplier_risk_summaries, max_alerts=5): + return RiskExplanationResult( + generated_at=dashboard.generated_at, + gateway_status=GatewayExplanationStatus( + reachable=True, + base_url="http://127.0.0.1:19000/v1", + health_url="http://127.0.0.1:19000/health", + model="demo-model", + ), + dashboard_summary="当前整体风险集中在库存缺口与供应商集中度。", + alert_explanations=[], + ) + + +def test_api_explanations_route(tmp_path) -> None: + service = SupplyChainAlertApplicationService( + storage_path=tmp_path / "supply-chain-state.json", + risk_explainer=FakeRiskExplainer(), + ) + service.run_demo(reset=True) + client = TestClient(create_fastapi_app(service)) + + response = client.get("/alerts/explanations") + + assert response.status_code == 200 + body = response.json() + assert body["gateway_status"]["reachable"] is True + assert body["dashboard_summary"] diff --git a/apps/tests/supply_chain_alert/test_detection.py b/apps/tests/supply_chain_alert/test_detection.py new file mode 100644 index 0000000..d2c3aaf --- /dev/null +++ b/apps/tests/supply_chain_alert/test_detection.py @@ -0,0 +1,237 @@ +from sage.apps.supply_chain_alert import SupplyChainAlertApplicationService +from sage.apps.supply_chain_alert.models import ( + InventoryEvent, + PurchaseOrderEvent, + ShipmentEvent, + SupplierPerformanceEvent, +) + + +def test_low_inventory_triggers_reallocation_recommendation(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + + result = service.ingest_events( + [ + InventoryEvent( + event_id="inventory:bj:chip-a:1", + sku="CHIP-A", + warehouse="WH-BJ", + current_stock=80, + safety_stock=20, + updated_at="2026-01-15T09:00:00", + ), + InventoryEvent( + event_id="inventory:sh:chip-a:1", + sku="CHIP-A", + warehouse="WH-SH", + current_stock=10, + safety_stock=30, + updated_at="2026-01-15T09:01:00", + ), + ] + ) + + low_inventory_alert = next(item for item in result.alerts if item.rule_id == "low_inventory") + + assert low_inventory_alert.risk_level in {"medium", "high"} + assert any("调拨" in action for action in low_inventory_alert.recommended_actions) + + +def test_delayed_shipment_is_assessed_as_high_risk(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + + result = service.ingest_events( + [ + PurchaseOrderEvent( + event_id="po:1", + order_id="PO-1", + sku="SENSOR-B", + quantity=25, + warehouse="WH-SH", + supplier_id="SUP-BETA", + created_at="2026-01-15T08:00:00", + promised_delivery="2026-01-15T20:00:00", + observed_at="2026-01-15T08:00:00", + status="shipped", + ), + ShipmentEvent( + event_id="shipment:1", + shipment_id="SHIP-1", + order_id="PO-1", + sku="SENSOR-B", + warehouse="WH-SH", + supplier_id="SUP-BETA", + current_location="Suzhou Hub", + status="in_transit", + promised_delivery="2026-01-15T20:00:00", + estimated_arrival="2026-01-16T10:00:00", + recorded_at="2026-01-15T12:00:00", + delay_hours=14.0, + status_age_hours=10.0, + ), + ] + ) + + delayed_alert = next(item for item in result.alerts if item.rule_id == "shipment_delay") + + assert delayed_alert.risk_level == "high" + assert delayed_alert.affected_order_count == 1 + assert any("加急" in action for action in delayed_alert.recommended_actions) + + +def test_supplier_recommendations_are_sorted_by_risk(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + + result = service.ingest_events( + [ + SupplierPerformanceEvent( + event_id="supplier:alpha", + supplier_id="SUP-ALPHA", + supplier_name="Alpha Components", + on_time_rate=0.72, + defect_rate=0.04, + breach_count_30d=5, + observed_at="2026-01-15T09:00:00", + ), + SupplierPerformanceEvent( + event_id="supplier:gamma", + supplier_id="SUP-GAMMA", + supplier_name="Gamma Electronics", + on_time_rate=0.98, + defect_rate=0.008, + breach_count_30d=0, + observed_at="2026-01-15T09:01:00", + ), + SupplierPerformanceEvent( + event_id="supplier:beta", + supplier_id="SUP-BETA", + supplier_name="Beta Logistics Parts", + on_time_rate=0.92, + defect_rate=0.015, + breach_count_30d=1, + observed_at="2026-01-15T09:02:00", + ), + PurchaseOrderEvent( + event_id="po:alpha-1", + order_id="PO-ALPHA-1", + sku="CHIP-A", + quantity=70, + warehouse="WH-SH", + supplier_id="SUP-ALPHA", + created_at="2026-01-14T09:00:00", + promised_delivery="2026-01-16T09:00:00", + observed_at="2026-01-15T09:03:00", + status="pending", + ), + PurchaseOrderEvent( + event_id="po:alpha-2", + order_id="PO-ALPHA-2", + sku="CHIP-A", + quantity=30, + warehouse="WH-SH", + supplier_id="SUP-ALPHA", + created_at="2026-01-14T10:00:00", + promised_delivery="2026-01-16T10:00:00", + observed_at="2026-01-15T09:04:00", + status="pending", + ), + PurchaseOrderEvent( + event_id="po:gamma-1", + order_id="PO-GAMMA-1", + sku="CHIP-A", + quantity=10, + warehouse="WH-SH", + supplier_id="SUP-GAMMA", + created_at="2026-01-14T11:00:00", + promised_delivery="2026-01-16T11:00:00", + observed_at="2026-01-15T09:05:00", + status="pending", + ), + ] + ) + + concentration_alert = next( + item for item in result.alerts if item.rule_id == "supplier_concentration" + ) + + assert concentration_alert.alternative_suppliers + assert concentration_alert.alternative_suppliers[0] == "SUP-GAMMA" + + +def test_supplier_recommendations_ignore_unrelated_suppliers(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + + result = service.ingest_events( + [ + SupplierPerformanceEvent( + event_id="supplier:alpha", + supplier_id="SUP-ALPHA", + supplier_name="Alpha Components", + on_time_rate=0.72, + defect_rate=0.04, + breach_count_30d=5, + observed_at="2026-01-15T09:00:00", + ), + SupplierPerformanceEvent( + event_id="supplier:gamma", + supplier_id="SUP-GAMMA", + supplier_name="Gamma Electronics", + on_time_rate=0.98, + defect_rate=0.008, + breach_count_30d=0, + observed_at="2026-01-15T09:01:00", + ), + SupplierPerformanceEvent( + event_id="supplier:delta", + supplier_id="SUP-DELTA", + supplier_name="Delta Packaging", + on_time_rate=0.99, + defect_rate=0.005, + breach_count_30d=0, + observed_at="2026-01-15T09:02:00", + ), + PurchaseOrderEvent( + event_id="po:alpha-1", + order_id="PO-ALPHA-1", + sku="CHIP-A", + quantity=60, + warehouse="WH-SH", + supplier_id="SUP-ALPHA", + created_at="2026-01-14T09:00:00", + promised_delivery="2026-01-16T09:00:00", + observed_at="2026-01-15T09:03:00", + status="pending", + ), + PurchaseOrderEvent( + event_id="po:gamma-1", + order_id="PO-GAMMA-1", + sku="CHIP-A", + quantity=10, + warehouse="WH-SH", + supplier_id="SUP-GAMMA", + created_at="2026-01-14T10:00:00", + promised_delivery="2026-01-16T10:00:00", + observed_at="2026-01-15T09:04:00", + status="pending", + ), + PurchaseOrderEvent( + event_id="po:alpha-2", + order_id="PO-ALPHA-2", + sku="CHIP-A", + quantity=30, + warehouse="WH-SH", + supplier_id="SUP-ALPHA", + created_at="2026-01-14T11:00:00", + promised_delivery="2026-01-16T11:00:00", + observed_at="2026-01-15T09:05:00", + status="pending", + ), + ] + ) + + concentration_alert = next( + item for item in result.alerts if item.rule_id == "supplier_concentration" + ) + + assert "SUP-GAMMA" in concentration_alert.alternative_suppliers + assert "SUP-DELTA" not in concentration_alert.alternative_suppliers diff --git a/apps/tests/supply_chain_alert/test_llm.py b/apps/tests/supply_chain_alert/test_llm.py new file mode 100644 index 0000000..985bbd5 --- /dev/null +++ b/apps/tests/supply_chain_alert/test_llm.py @@ -0,0 +1,356 @@ +import json + +from sage.apps.supply_chain_alert import SupplyChainAlertApplicationService +from sage.apps.supply_chain_alert.llm import ( + SupplyChainGatewaySettings, + SupplyChainRiskExplainer, +) +from sage.serving import GatewayProbeResult + + +def test_gateway_settings_from_env(monkeypatch) -> None: + monkeypatch.setenv("SAGE_SUPPLY_CHAIN_GATEWAY_HOST", "10.0.0.8") + monkeypatch.setenv("SAGE_SUPPLY_CHAIN_GATEWAY_PORT", "18080") + monkeypatch.setenv("SAGE_SUPPLY_CHAIN_MODEL", "demo-model") + monkeypatch.setenv("SAGE_SUPPLY_CHAIN_API_KEY", "gateway-key") # pragma: allowlist secret + + settings = SupplyChainGatewaySettings.from_env() + + assert settings.host == "10.0.0.8" + assert settings.port == 18080 + assert settings.model == "demo-model" + assert settings.api_key == "gateway-key" # pragma: allowlist secret + + +def test_gateway_settings_support_remote_base_url(monkeypatch) -> None: + monkeypatch.setenv("SAGE_SUPPLY_CHAIN_BASE_URL", "https://api.sage.org.ai/v1") + monkeypatch.setenv("SAGE_SUPPLY_CHAIN_HEALTH_URL", "https://api.sage.org.ai/health") + + settings = SupplyChainGatewaySettings.from_env() + + assert settings.base_url == "https://api.sage.org.ai/v1" + assert settings.health_url == "https://api.sage.org.ai/health" + + +def test_explainer_reports_unavailable_gateway(tmp_path, monkeypatch) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + service.run_demo(reset=True) + explainer = SupplyChainRiskExplainer( + settings=SupplyChainGatewaySettings(host="127.0.0.1", port=19000), + ) + + monkeypatch.setattr( + "sage.apps.supply_chain_alert.llm.probe_gateway", + lambda config, timeout=3.0: GatewayProbeResult( + ok=False, + url=config.health_url, + error="connection refused", + ), + ) + + result = explainer.explain_current_risks( + dashboard=service.get_dashboard(), + alerts=service.list_open_alerts(), + supplier_risk_summaries=service.get_supplier_risk_summary(), + ) + + assert result.gateway_status.reachable is False + assert result.gateway_status.error == "connection refused" + assert result.alert_explanations == [] + assert result.dashboard_summary is None + + +def test_explainer_generates_dashboard_and_alert_explanations(tmp_path, monkeypatch) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + service.run_demo(reset=True) + explainer = SupplyChainRiskExplainer( + settings=SupplyChainGatewaySettings(host="127.0.0.1", port=19000), + ) + + monkeypatch.setattr( + "sage.apps.supply_chain_alert.llm.probe_gateway", + lambda config, timeout=3.0: GatewayProbeResult( + ok=True, + url=config.health_url, + status_code=200, + ), + ) + monkeypatch.setattr(explainer, "_build_client", lambda: object()) + monkeypatch.setattr(explainer, "_resolve_model", lambda client: "demo-model") + monkeypatch.setattr( + explainer, + "generate_dashboard_summary", + lambda **kwargs: "当前整体风险集中在低库存和订单积压。", + ) + monkeypatch.setattr( + explainer, + "generate_alert_explanation", + lambda **kwargs: "这条预警说明当前订单履约存在明显延迟风险,需要优先处理。", + ) + + result = explainer.explain_current_risks( + dashboard=service.get_dashboard(), + alerts=service.list_open_alerts(), + supplier_risk_summaries=service.get_supplier_risk_summary(), + max_alerts=2, + ) + + assert result.gateway_status.reachable is True + assert result.gateway_status.model == "demo-model" + assert result.dashboard_summary == "当前整体风险集中在低库存和订单积压。" + assert len(result.alert_explanations) == 2 + + +def test_explainer_probes_remote_base_url(tmp_path, monkeypatch) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + service.run_demo(reset=True) + explainer = SupplyChainRiskExplainer( + settings=SupplyChainGatewaySettings( + base_url_override="https://api.sage.org.ai/v1", + health_url_override="https://api.sage.org.ai/health", + api_key="remote-key", # pragma: allowlist secret + ), + ) + + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return {"status": "ok"} + + class FakeClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + assert url == "https://api.sage.org.ai/health" + return FakeResponse() + + monkeypatch.setattr("sage.apps.supply_chain_alert.llm.httpx.Client", FakeClient) + monkeypatch.setattr(explainer, "_build_client", lambda: object()) + monkeypatch.setattr(explainer, "_resolve_model", lambda client: "demo-model") + monkeypatch.setattr( + explainer, + "generate_dashboard_summary", + lambda **kwargs: "远程网关已正常生成摘要。", + ) + monkeypatch.setattr( + explainer, + "generate_alert_explanation", + lambda **kwargs: "远程网关已正常生成预警解释。", + ) + + result = explainer.explain_current_risks( + dashboard=service.get_dashboard(), + alerts=service.list_open_alerts(), + supplier_risk_summaries=service.get_supplier_risk_summary(), + max_alerts=1, + ) + + assert result.gateway_status.reachable is True + assert result.gateway_status.base_url == "https://api.sage.org.ai/v1" + assert result.dashboard_summary == "远程网关已正常生成摘要。" + assert len(result.alert_explanations) == 1 + + +def test_explainer_remote_base_url_can_bypass_failed_health_probe(tmp_path, monkeypatch) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + service.run_demo(reset=True) + explainer = SupplyChainRiskExplainer( + settings=SupplyChainGatewaySettings( + base_url_override="https://api.sage.org.ai/v1", + health_url_override="https://api.sage.org.ai/health", + api_key="remote-key", # pragma: allowlist secret + ), + ) + + class FakeResponse: + status_code = 504 + text = "upstream timeout" + + @staticmethod + def json(): + raise ValueError("not json") + + class FakeClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + assert url == "https://api.sage.org.ai/health" + return FakeResponse() + + monkeypatch.setattr("sage.apps.supply_chain_alert.llm.httpx.Client", FakeClient) + monkeypatch.setattr(explainer, "_build_client", lambda: object()) + monkeypatch.setattr(explainer, "_resolve_model", lambda client: "qwen7b-a100") + monkeypatch.setattr( + explainer, + "generate_dashboard_summary", + lambda **kwargs: "虽然 health 探测失败,但远程模型接口可用,已生成摘要。", + ) + monkeypatch.setattr( + explainer, + "generate_alert_explanation", + lambda **kwargs: "远程模型接口可用,已生成预警解释。", + ) + + result = explainer.explain_current_risks( + dashboard=service.get_dashboard(), + alerts=service.list_open_alerts(), + supplier_risk_summaries=service.get_supplier_risk_summary(), + max_alerts=1, + ) + + assert result.gateway_status.reachable is True + assert result.gateway_status.model == "qwen7b-a100" + assert result.gateway_status.status_code is None + assert result.gateway_status.error is None + assert result.dashboard_summary == "虽然 health 探测失败,但远程模型接口可用,已生成摘要。" + assert len(result.alert_explanations) == 1 + + +def test_explainer_remote_base_url_uses_direct_httpx_calls(tmp_path, monkeypatch) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + service.run_demo(reset=True) + explainer = SupplyChainRiskExplainer( + settings=SupplyChainGatewaySettings( + base_url_override="https://api.sage.org.ai/v1", + health_url_override="https://api.sage.org.ai/health", + api_key="remote-key", # pragma: allowlist secret + model="Qwen/Qwen2.5-7B-Instruct", + ), + ) + + class FakeResponse: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload, ensure_ascii=False) + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(self.text) + + class FakeClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.posts = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + if url.endswith("/health"): + return FakeResponse(504, {"error": "upstream timeout"}) + if url.endswith("/models"): + return FakeResponse(200, {"data": [{"id": "Qwen/Qwen2.5-7B-Instruct"}]}) + raise AssertionError(url) + + def post(self, url, json): + self.posts.append((url, json)) + return FakeResponse( + 200, + { + "choices": [ + { + "message": { + "content": "这是远程网关返回的解释。先说明触发原因,再说明影响。" + } + } + ] + }, + ) + + monkeypatch.setattr("sage.apps.supply_chain_alert.llm.httpx.Client", FakeClient) + + result = explainer.explain_current_risks( + dashboard=service.get_dashboard(), + alerts=service.list_open_alerts(), + supplier_risk_summaries=service.get_supplier_risk_summary(), + max_alerts=1, + ) + + assert result.gateway_status.reachable is True + assert result.gateway_status.error is None + assert result.gateway_status.status_code is None + assert result.dashboard_summary + assert len(result.alert_explanations) == 1 + assert "远程网关返回的解释" in result.alert_explanations[0].explanation + + +def test_explainer_remote_base_url_reports_actual_generation_error(tmp_path, monkeypatch) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + service.run_demo(reset=True) + explainer = SupplyChainRiskExplainer( + settings=SupplyChainGatewaySettings( + base_url_override="https://api.sage.org.ai/v1", + health_url_override="https://api.sage.org.ai/health", + api_key="remote-key", # pragma: allowlist secret + model="Qwen/Qwen2.5-7B-Instruct", + ), + ) + + class FakeResponse: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload, ensure_ascii=False) + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(self.text) + + class FakeClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + if url.endswith("/health"): + return FakeResponse(504, {"error": "upstream timeout"}) + raise AssertionError(url) + + def post(self, url, json): + return FakeResponse(403, {"error": {"message": "request blocked"}}) + + monkeypatch.setattr("sage.apps.supply_chain_alert.llm.httpx.Client", FakeClient) + + result = explainer.explain_current_risks( + dashboard=service.get_dashboard(), + alerts=service.list_open_alerts(), + supplier_risk_summaries=service.get_supplier_risk_summary(), + max_alerts=1, + ) + + assert result.gateway_status.reachable is False + assert result.gateway_status.status_code == 504 + assert "request blocked" in (result.gateway_status.error or "") + assert result.dashboard_summary is None + assert result.alert_explanations == [] diff --git a/apps/tests/supply_chain_alert/test_service.py b/apps/tests/supply_chain_alert/test_service.py new file mode 100644 index 0000000..37c7a74 --- /dev/null +++ b/apps/tests/supply_chain_alert/test_service.py @@ -0,0 +1,59 @@ +from sage.apps.supply_chain_alert import ( + GatewayExplanationStatus, + RiskExplanationResult, + SupplyChainAlertApplicationService, +) +from sage.apps.supply_chain_alert.demo_data import build_demo_supply_events + + +def test_state_persists_across_service_instances(tmp_path) -> None: + storage_path = tmp_path / "supply-chain-state.json" + first_service = SupplyChainAlertApplicationService(storage_path=storage_path) + + first_result = first_service.ingest_events(build_demo_supply_events()) + + second_service = SupplyChainAlertApplicationService(storage_path=storage_path) + dashboard = second_service.get_dashboard() + alerts = second_service.list_open_alerts() + + assert dashboard.open_alert_count == first_result.dashboard.open_alert_count + assert len(alerts) == first_result.alert_count + + +def test_run_demo_resets_previous_state_by_default(tmp_path) -> None: + storage_path = tmp_path / "supply-chain-state.json" + service = SupplyChainAlertApplicationService(storage_path=storage_path) + + first_result = service.run_demo(reset=True) + second_result = service.run_demo(reset=True) + + assert first_result.alert_count == second_result.alert_count + assert service.get_dashboard().open_alert_count == second_result.alert_count + + +class FakeRiskExplainer: + def explain_current_risks(self, *, dashboard, alerts, supplier_risk_summaries, max_alerts=5): + return RiskExplanationResult( + generated_at=dashboard.generated_at, + gateway_status=GatewayExplanationStatus( + reachable=True, + base_url="http://127.0.0.1:19000/v1", + health_url="http://127.0.0.1:19000/health", + model="demo-model", + ), + dashboard_summary="当前风险以单一供应商依赖和低库存为主。", + alert_explanations=[], + ) + + +def test_service_can_return_gateway_explanations(tmp_path) -> None: + service = SupplyChainAlertApplicationService( + storage_path=tmp_path / "supply-chain-state.json", + risk_explainer=FakeRiskExplainer(), + ) + service.run_demo(reset=True) + + result = service.explain_current_risks(max_alerts=3) + + assert result.gateway_status.reachable is True + assert "低库存" in result.dashboard_summary diff --git a/apps/tests/supply_chain_alert/test_workflow.py b/apps/tests/supply_chain_alert/test_workflow.py new file mode 100644 index 0000000..2cff2ae --- /dev/null +++ b/apps/tests/supply_chain_alert/test_workflow.py @@ -0,0 +1,29 @@ +from sage.apps.supply_chain_alert import SupplyChainAlertApplicationService + + +def test_run_demo_generates_multiple_alert_types(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + + result = service.run_demo(reset=True) + rule_ids = {item.rule_id for item in result.alerts} + + assert result.processed_event_count == 10 + assert result.alert_count >= 5 + assert "low_inventory" in rule_ids + assert "shipment_delay" in rule_ids + assert "supplier_risk_deterioration" in rule_ids + assert result.dashboard.open_alert_count >= 5 + assert result.dashboard.high_risk_alert_count >= 3 + + +def test_dashboard_summary_exposes_expected_metrics(tmp_path) -> None: + service = SupplyChainAlertApplicationService(storage_path=tmp_path / "supply-chain-state.json") + + service.run_demo(reset=True) + dashboard = service.get_dashboard() + + assert dashboard.low_stock_sku_count >= 1 + assert dashboard.delayed_shipment_count == 1 + assert dashboard.overdue_order_count >= 1 + assert dashboard.average_delay_hours == 12.0 + assert "CHIP-A" in dashboard.top_shortage_skus diff --git a/apps/tests/ticket_triage/test_api.py b/apps/tests/ticket_triage/test_api.py new file mode 100644 index 0000000..3d45359 --- /dev/null +++ b/apps/tests/ticket_triage/test_api.py @@ -0,0 +1,88 @@ +from fastapi.testclient import TestClient + +from sage.apps.ticket_triage import TicketTriageApplicationService, create_fastapi_app +from sage.apps.ticket_triage.demo_data import build_demo_tickets + + +def test_api_root_and_health_routes(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + client = TestClient(create_fastapi_app(service)) + + root_response = client.get("/") + health_response = client.get("/health") + + assert root_response.status_code == 200 + assert root_response.json()["name"] == "SAGE Ticket Triage MVP" + assert "/docs" in root_response.json()["routes"] + assert "/redoc" in root_response.json()["routes"] + assert "/dashboard" in root_response.json()["routes"] + assert "/dashboard/ui" in root_response.json()["routes"] + assert "/tickets" in root_response.json()["routes"] + assert "/tickets/ingest" in root_response.json()["routes"] + assert "/demo/reset-and-run" in root_response.json()["routes"] + assert health_response.status_code == 200 + assert health_response.json() == {"status": "ok"} + + +def test_api_demo_and_query_routes(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + client = TestClient(create_fastapi_app(service)) + + demo_response = client.post("/demo/reset-and-run") + dashboard_response = client.get("/dashboard") + tickets_response = client.get("/tickets") + queues_response = client.get("/queues") + high_priority_response = client.get("/tickets/high-priority") + ticket_response = client.get("/tickets/T-1001") + + assert demo_response.status_code == 200 + payload = demo_response.json() + assert payload["processed_event_count"] == 10 + assert payload["high_priority_count"] >= 5 + + assert dashboard_response.status_code == 200 + assert dashboard_response.json()["total_ticket_count"] == 10 + assert dashboard_response.json()["high_priority_count"] >= 5 + + assert tickets_response.status_code == 200 + assert len(tickets_response.json()) == 10 + + assert queues_response.status_code == 200 + assert queues_response.json() + + assert high_priority_response.status_code == 200 + assert high_priority_response.json() + assert all(item["priority"] in {"high", "critical"} for item in high_priority_response.json()) + + assert ticket_response.status_code == 200 + assert ticket_response.json()["ticket_id"] == "T-1001" + + +def test_api_ingest_route_and_missing_ticket(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + client = TestClient(create_fastapi_app(service)) + + ingest_response = client.post( + "/tickets/ingest", + json=[item.to_dict() for item in build_demo_tickets()[:2]], + ) + missing_response = client.get("/tickets/NOT-FOUND") + + assert ingest_response.status_code == 200 + assert ingest_response.json()["processed_event_count"] == 2 + + assert missing_response.status_code == 404 + assert "No ticket found" in missing_response.json()["detail"] + + +def test_api_dashboard_ui_route(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + client = TestClient(create_fastapi_app(service)) + + response = client.get("/dashboard/ui") + + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "客服工单分诊看板" in response.text + assert "加载演示数据" in response.text + assert "提交单条工单" in response.text diff --git a/apps/tests/ticket_triage/test_routing_rules.py b/apps/tests/ticket_triage/test_routing_rules.py new file mode 100644 index 0000000..0770726 --- /dev/null +++ b/apps/tests/ticket_triage/test_routing_rules.py @@ -0,0 +1,55 @@ +from sage.apps.ticket_triage import TicketEvent, TicketTriageApplicationService + + +def test_invoice_question_prefers_billing_and_auto_reply(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + + result = service.ingest_tickets( + [ + TicketEvent( + ticket_id="T-INVOICE-1", + channel="email", + customer_id="CUST-X1", + subject="电子发票怎么下载", + message="我需要下载电子发票做报销,请给我入口。", + created_at="2026-04-20T10:00:00", + ) + ] + ) + triage = result.triage_results[0] + + assert triage.intent == "invoice_issue" + assert triage.assigned_team == "billing_ops" + assert triage.auto_reply is True + assert "发票" in triage.reply_draft + + +def test_repeat_login_issue_escalates_priority(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + + result = service.ingest_tickets( + [ + TicketEvent( + ticket_id="T-LOGIN-1", + channel="email", + customer_id="CUST-X2", + subject="无法登录", + message="系统提示账号锁定。", + created_at="2026-04-20T10:00:00", + ), + TicketEvent( + ticket_id="T-LOGIN-2", + channel="chat", + customer_id="CUST-X2", + subject="还是无法登录", + message="我已经等待两天,账号锁定问题还没解决,请尽快处理。", + created_at="2026-04-20T10:20:00", + attachments=["locked-account.png"], + ), + ] + ) + follow_up = result.triage_results[-1] + + assert follow_up.intent == "complaint_escalation" + assert follow_up.priority in {"high", "critical"} + assert follow_up.assigned_team == "duty_manager" diff --git a/apps/tests/ticket_triage/test_service.py b/apps/tests/ticket_triage/test_service.py new file mode 100644 index 0000000..8581ac7 --- /dev/null +++ b/apps/tests/ticket_triage/test_service.py @@ -0,0 +1,29 @@ +from sage.apps.ticket_triage import TicketTriageApplicationService +from sage.apps.ticket_triage.demo_data import build_demo_tickets + + +def test_state_persists_across_service_instances(tmp_path) -> None: + storage_path = tmp_path / "ticket-state.json" + first_service = TicketTriageApplicationService(storage_path=storage_path) + + first_result = first_service.ingest_tickets(build_demo_tickets()) + + second_service = TicketTriageApplicationService(storage_path=storage_path) + queue_summary = second_service.list_queue_summary() + snapshot = second_service.get_ticket("T-1001") + + assert ( + sum(item.open_ticket_count for item in queue_summary) == first_result.processed_event_count + ) + assert snapshot.intent == "login_issue" + + +def test_run_demo_resets_previous_state_by_default(tmp_path) -> None: + storage_path = tmp_path / "ticket-state.json" + service = TicketTriageApplicationService(storage_path=storage_path) + + first_result = service.run_demo(reset=True) + second_result = service.run_demo(reset=True) + + assert first_result.processed_event_count == second_result.processed_event_count + assert len(service.list_open_high_priority_tickets()) == second_result.high_priority_count diff --git a/apps/tests/ticket_triage/test_workflow.py b/apps/tests/ticket_triage/test_workflow.py new file mode 100644 index 0000000..8164a6c --- /dev/null +++ b/apps/tests/ticket_triage/test_workflow.py @@ -0,0 +1,27 @@ +from sage.apps.ticket_triage import TicketTriageApplicationService + + +def test_run_demo_generates_multiple_queues_and_priorities(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + + result = service.run_demo(reset=True) + + intents = {item.intent for item in result.triage_results} + queue_names = {item.team_name for item in result.queue_summaries} + + assert result.processed_event_count == 10 + assert result.high_priority_count >= 5 + assert "login_issue" in intents + assert "refund_request" in intents + assert "duty_manager" in queue_names + assert "technical_support" in queue_names + + +def test_demo_creates_high_priority_ticket_snapshots(tmp_path) -> None: + service = TicketTriageApplicationService(storage_path=tmp_path / "ticket-state.json") + + service.run_demo(reset=True) + high_priority_tickets = service.list_open_high_priority_tickets() + + assert len(high_priority_tickets) >= 5 + assert all(item.priority in {"high", "critical"} for item in high_priority_tickets) diff --git a/docs/100-app-roadmap.md b/docs/100-app-roadmap.md index d9d396f..9378917 100644 --- a/docs/100-app-roadmap.md +++ b/docs/100-app-roadmap.md @@ -3,17 +3,17 @@ This document is the planning baseline for expanding `sage-examples` into a portfolio of 100 meaningful SAGE applications. -The goal here is not to create 100 empty scripts. The goal is to define 100 application targets -that are worth building, prioritize them in rational batches, and keep future implementation -consistent with the current repository structure. +The goal here is not to create 100 empty scripts. The goal is to define 100 application targets that +are worth building, prioritize them in rational batches, and keep future implementation consistent +with the current repository structure. ## Portfolio Rules - Keep runnable entry points in `examples/` only when an app is actually maintained. - Keep implementation code in `apps/src/sage/apps//`. - Keep each runnable script named `examples/run_.py`. -- Prefer apps that demonstrate one or more SAGE strengths: stateful workflows, streaming, - multimodal processing, retrieval, agentic coordination, or service orchestration. +- Prefer apps that demonstrate one or more SAGE strengths: stateful workflows, streaming, multimodal + processing, retrieval, agentic coordination, or service orchestration. - Prefer fail-fast application flows over hidden fallback logic. - Do not add new `ray` dependencies or imports. @@ -36,133 +36,211 @@ consistent with the current repository structure. ### Research And Knowledge Systems -1. `literature_report_assistant` | `existing` | Personalized paper discovery, clustering, and reading report generation. -2. `article_monitoring` | `existing` | Continuous article monitoring with topic extraction and alerting. -3. `patent_landscape_mapper` | `B1` | Map patent trends, assignees, claim overlap, and white-space opportunities. -4. `grant_call_matcher` | `B1` | Match research groups or startups to grant calls and generate opportunity briefs. -5. `experiment_log_summarizer` | `B1` | Convert lab logs into structured daily summaries, anomalies, and next actions. -6. `scientific_claim_verifier` | `B2` | Check whether a scientific claim is supported, mixed, or contradicted by sources. -7. `conference_trend_monitor` | `B2` | Track conference sessions, speakers, themes, and emerging topic shifts. -8. `peer_review_copilot` | `B2` | Produce structured review rubrics, evidence notes, and revision requests. -9. `reproducibility_auditor` | `B2` | Audit datasets, configs, scripts, and environment metadata for reproducibility. -10. `benchmark_change_tracker` | `B1` | Track benchmark leaderboards, evaluation changes, and model movement over time. +1. `literature_report_assistant` | `existing` | Personalized paper discovery, clustering, and + reading report generation. +1. `article_monitoring` | `existing` | Continuous article monitoring with topic extraction and + alerting. +1. `patent_landscape_mapper` | `B1` | Map patent trends, assignees, claim overlap, and white-space + opportunities. +1. `grant_call_matcher` | `B1` | Match research groups or startups to grant calls and generate + opportunity briefs. +1. `experiment_log_summarizer` | `B1` | Convert lab logs into structured daily summaries, anomalies, + and next actions. +1. `scientific_claim_verifier` | `B2` | Check whether a scientific claim is supported, mixed, or + contradicted by sources. +1. `conference_trend_monitor` | `B2` | Track conference sessions, speakers, themes, and emerging + topic shifts. +1. `peer_review_copilot` | `B2` | Produce structured review rubrics, evidence notes, and revision + requests. +1. `reproducibility_auditor` | `B2` | Audit datasets, configs, scripts, and environment metadata for + reproducibility. +1. `benchmark_change_tracker` | `B1` | Track benchmark leaderboards, evaluation changes, and model + movement over time. ### Education And Training -11. `student_improvement` | `existing` | Stateful score-improvement workflow with wrong-question tracking. -12. `course_qa_tutor` | `B1` | Retrieval-based course tutor over notes, slides, and assignments. -13. `lesson_plan_designer` | `B1` | Generate lesson plans, weekly pacing, and differentiated exercises. -14. `classroom_attention_monitor` | `B2` | Analyze classroom video for engagement, confusion, and pacing signals. -15. `assignment_feedback_assistant` | `B1` | Provide rubric-based draft feedback before teacher review. -16. `oral_exam_coach` | `B2` | Evaluate spoken answers and generate targeted follow-up drills. -17. `skill_gap_mapper` | `B1` | Map learner performance to a competency graph and recommend practice paths. -18. `training_compliance_tracker` | `B1` | Monitor enterprise training completion and highlight compliance risks. -19. `interview_practice_coach` | `B1` | Simulate interviews, score responses, and suggest role-specific improvements. -20. `campus_service_desk` | `B2` | Route student requests for finance, housing, advising, and administration. +11. `student_improvement` | `existing` | Stateful score-improvement workflow with wrong-question + tracking. +01. `course_qa_tutor` | `B1` | Retrieval-based course tutor over notes, slides, and assignments. +01. `lesson_plan_designer` | `B1` | Generate lesson plans, weekly pacing, and differentiated + exercises. +01. `classroom_attention_monitor` | `B2` | Analyze classroom video for engagement, confusion, and + pacing signals. +01. `assignment_feedback_assistant` | `B1` | Provide rubric-based draft feedback before teacher + review. +01. `oral_exam_coach` | `B2` | Evaluate spoken answers and generate targeted follow-up drills. +01. `skill_gap_mapper` | `B1` | Map learner performance to a competency graph and recommend practice + paths. +01. `training_compliance_tracker` | `B1` | Monitor enterprise training completion and highlight + compliance risks. +01. `interview_practice_coach` | `B1` | Simulate interviews, score responses, and suggest + role-specific improvements. +01. `campus_service_desk` | `B2` | Route student requests for finance, housing, advising, and + administration. ### Healthcare And Life Sciences 21. `medical_diagnosis` | `existing` | AI-assisted medical image analysis workflow. -22. `triage_intake_assistant` | `B2` | Structure patient intake information and prioritize triage queues. -23. `radiology_followup_tracker` | `B2` | Detect follow-up recommendations in reports and track unresolved cases. -24. `clinical_guideline_copilot` | `B2` | Link patient context to guideline sections and action checklists. -25. `adverse_event_monitor` | `B2` | Monitor notes, reports, and signals for potential adverse events. -26. `medical_claim_auditor` | `B3` | Review claim packets for coding gaps, missing evidence, and denial risk. -27. `eldercare_home_monitor` | `B3` | Combine home-device signals and notes to flag decline or safety issues. -28. `hospital_bed_flow_coordinator` | `B3` | Summarize bed status, discharge blockers, and escalation priorities. -29. `drug_label_extractor` | `B1` | Extract dosage, contraindications, and warnings from drug labels. -30. `telehealth_visit_summarizer` | `B1` | Turn telehealth transcripts into structured visit notes and action items. +01. `triage_intake_assistant` | `B2` | Structure patient intake information and prioritize triage + queues. +01. `radiology_followup_tracker` | `B2` | Detect follow-up recommendations in reports and track + unresolved cases. +01. `clinical_guideline_copilot` | `B2` | Link patient context to guideline sections and action + checklists. +01. `adverse_event_monitor` | `B2` | Monitor notes, reports, and signals for potential adverse + events. +01. `medical_claim_auditor` | `B3` | Review claim packets for coding gaps, missing evidence, and + denial risk. +01. `eldercare_home_monitor` | `B3` | Combine home-device signals and notes to flag decline or + safety issues. +01. `hospital_bed_flow_coordinator` | `B3` | Summarize bed status, discharge blockers, and + escalation priorities. +01. `drug_label_extractor` | `B1` | Extract dosage, contraindications, and warnings from drug + labels. +01. `telehealth_visit_summarizer` | `B1` | Turn telehealth transcripts into structured visit notes + and action items. ### Enterprise Productivity And Operations -31. `work_report_generator` | `existing` | Generate work reports from activity history and project context. -32. `meeting_action_tracker` | `B1` | Extract decisions, owners, deadlines, and unresolved blockers from meetings. -33. `contract_review_assistant` | `B2` | Highlight risky clauses, obligations, and missing terms in contracts. -34. `sales_call_insights` | `B1` | Summarize objections, buying signals, and follow-up priorities from calls. -35. `customer_support_copilot` | `B1` | Draft grounded responses and route tickets by issue type and urgency. -36. `procurement_analyzer` | `B2` | Compare supplier quotes, delivery risk, and negotiation leverage. -37. `org_policy_search` | `B1` | Answer internal policy questions with source-grounded citations. -38. `internal_ticket_router` | `B1` | Route IT, HR, and finance requests to the right queue with context. -39. `knowledge_base_curator` | `B1` | Deduplicate, summarize, and age-rank internal knowledge articles. -40. `multi_team_status_board` | `B2` | Aggregate project updates into a leadership-level cross-team digest. +31. `work_report_generator` | `existing` | Generate work reports from activity history and project + context. +01. `meeting_action_tracker` | `B1` | Extract decisions, owners, deadlines, and unresolved blockers + from meetings. +01. `contract_review_assistant` | `B2` | Highlight risky clauses, obligations, and missing terms in + contracts. +01. `sales_call_insights` | `B1` | Summarize objections, buying signals, and follow-up priorities + from calls. +01. `customer_support_copilot` | `B1` | Draft grounded responses and route tickets by issue type and + urgency. +01. `procurement_analyzer` | `B2` | Compare supplier quotes, delivery risk, and negotiation + leverage. +01. `org_policy_search` | `B1` | Answer internal policy questions with source-grounded citations. +01. `internal_ticket_router` | `B1` | Route IT, HR, and finance requests to the right queue with + context. +01. `knowledge_base_curator` | `B1` | Deduplicate, summarize, and age-rank internal knowledge + articles. +01. `multi_team_status_board` | `B2` | Aggregate project updates into a leadership-level cross-team + digest. ### Media And Creative Operations 41. `video_intelligence` | `existing` | Multimodal video understanding and event summarization. -42. `podcast_clip_generator` | `B1` | Find high-value podcast segments and generate clips with titles and notes. -43. `brand_asset_reviewer` | `B2` | Check copy, visuals, and metadata for brand consistency. -44. `livestream_incident_monitor` | `B3` | Detect incidents, dead air, and moderation risks in livestreams. -45. `ad_creative_tester` | `B2` | Compare ad variants, messaging hooks, and likely audience resonance. -46. `subtitle_qc_assistant` | `B1` | Detect subtitle timing, terminology, and translation quality issues. -47. `content_calendar_planner` | `B1` | Plan multi-channel content schedules based on themes and launches. -48. `social_trend_storyboard` | `B2` | Turn social trend signals into short-form content concepts. -49. `news_bias_analyzer` | `B2` | Compare framing and bias patterns across outlets on the same event. -50. `media_archive_indexer` | `B1` | Build searchable indexes over large media collections with summaries. +01. `podcast_clip_generator` | `B1` | Find high-value podcast segments and generate clips with + titles and notes. +01. `brand_asset_reviewer` | `B2` | Check copy, visuals, and metadata for brand consistency. +01. `livestream_incident_monitor` | `B3` | Detect incidents, dead air, and moderation risks in + livestreams. +01. `ad_creative_tester` | `B2` | Compare ad variants, messaging hooks, and likely audience + resonance. +01. `subtitle_qc_assistant` | `B1` | Detect subtitle timing, terminology, and translation quality + issues. +01. `content_calendar_planner` | `B1` | Plan multi-channel content schedules based on themes and + launches. +01. `social_trend_storyboard` | `B2` | Turn social trend signals into short-form content concepts. +01. `news_bias_analyzer` | `B2` | Compare framing and bias patterns across outlets on the same + event. +01. `media_archive_indexer` | `B1` | Build searchable indexes over large media collections with + summaries. ### IoT, Edge, And Physical World 51. `smart_home` | `existing` | Stateful IoT automation and home-event orchestration. -52. `building_energy_manager` | `B2` | Optimize occupancy, HVAC schedules, and energy anomalies. -53. `factory_sensor_watchdog` | `B2` | Detect abnormal sensor patterns and summarize likely causes. -54. `cold_chain_monitor` | `B2` | Track storage temperature excursions and delivery quality risk. -55. `retail_queue_monitor` | `B3` | Analyze camera and POS signals to predict queue pressure. -56. `parking_lot_coordinator` | `B2` | Combine camera, gate, and event data to manage parking flow. -57. `edge_safety_alerting` | `B3` | Detect unsafe zones, restricted access, and PPE violations at the edge. -58. `greenhouse_copilot` | `B2` | Orchestrate irrigation, climate signals, and crop alerting. -59. `fleet_dashcam_reviewer` | `B3` | Summarize risky driving events and coaching recommendations. -60. `warehouse_robot_supervisor` | `B3` | Detect stalled tasks, congestion, and robot-assist escalation needs. +01. `building_energy_manager` | `B2` | Optimize occupancy, HVAC schedules, and energy anomalies. +01. `factory_sensor_watchdog` | `B2` | Detect abnormal sensor patterns and summarize likely causes. +01. `cold_chain_monitor` | `B2` | Track storage temperature excursions and delivery quality risk. +01. `retail_queue_monitor` | `B3` | Analyze camera and POS signals to predict queue pressure. +01. `parking_lot_coordinator` | `B2` | Combine camera, gate, and event data to manage parking flow. +01. `edge_safety_alerting` | `B3` | Detect unsafe zones, restricted access, and PPE violations at + the edge. +01. `greenhouse_copilot` | `B2` | Orchestrate irrigation, climate signals, and crop alerting. +01. `fleet_dashcam_reviewer` | `B3` | Summarize risky driving events and coaching recommendations. +01. `warehouse_robot_supervisor` | `B3` | Detect stalled tasks, congestion, and robot-assist + escalation needs. ### Public Sector And Smart City -61. `emergency_signal_fusion` | `B3` | Fuse call-center, camera, and field-report signals into incident views. -62. `citizen_feedback_router` | `B1` | Classify citizen complaints and route them to departments with summaries. -63. `traffic_incident_briefing` | `B2` | Summarize traffic disruptions, likely impact, and response priorities. -64. `urban_maintenance_scheduler` | `B2` | Cluster repair requests and recommend efficient maintenance routing. -65. `environmental_complaint_analyzer` | `B1` | Detect recurring pollution complaints and hotspot trends. -66. `permit_review_assistant` | `B2` | Check permit submissions for missing materials and policy mismatches. -67. `utility_outage_triage` | `B3` | Correlate outage reports, asset telemetry, and restoration priorities. -68. `public_health_signal_watch` | `B3` | Monitor weak signals from clinics, news, and reports for outbreaks. -69. `disaster_resource_matcher` | `B3` | Match shelters, supplies, volunteers, and requests during emergencies. -70. `municipal_doc_search` | `B1` | Search city policies, council decisions, and service procedures. +61. `emergency_signal_fusion` | `B3` | Fuse call-center, camera, and field-report signals into + incident views. +01. `citizen_feedback_router` | `B1` | Classify citizen complaints and route them to departments + with summaries. +01. `traffic_incident_briefing` | `B2` | Summarize traffic disruptions, likely impact, and response + priorities. +01. `urban_maintenance_scheduler` | `B2` | Cluster repair requests and recommend efficient + maintenance routing. +01. `environmental_complaint_analyzer` | `B1` | Detect recurring pollution complaints and hotspot + trends. +01. `permit_review_assistant` | `B2` | Check permit submissions for missing materials and policy + mismatches. +01. `utility_outage_triage` | `B3` | Correlate outage reports, asset telemetry, and restoration + priorities. +01. `public_health_signal_watch` | `B3` | Monitor weak signals from clinics, news, and reports for + outbreaks. +01. `disaster_resource_matcher` | `B3` | Match shelters, supplies, volunteers, and requests during + emergencies. +01. `municipal_doc_search` | `B1` | Search city policies, council decisions, and service procedures. ### Finance And Risk -71. `invoice_exception_detector` | `B1` | Flag unusual invoice amounts, duplicate risk, and missing fields. -72. `expense_audit_assistant` | `B1` | Review reimbursements for policy violations and evidence gaps. -73. `loan_application_screening` | `B2` | Structure applicant packets and surface review priorities. -74. `aml_case_prioritizer` | `B3` | Rank anti-money-laundering cases by signal strength and analyst value. -75. `insurance_claim_triage` | `B2` | Classify claim complexity, fraud indicators, and next-review steps. -76. `portfolio_news_risk_monitor` | `B2` | Connect news events to holdings and summarize exposure impact. -77. `vendor_risk_scanner` | `B2` | Combine public signals, contracts, and incidents into vendor risk views. -78. `cashflow_forecaster` | `B2` | Blend invoicing, pipeline, and payment signals into short-term forecasts. -79. `pricing_anomaly_monitor` | `B2` | Detect suspicious price changes across products or accounts. -80. `billing_dispute_resolver` | `B1` | Organize billing evidence and draft dispute-resolution responses. +71. `invoice_exception_detector` | `B1` | Flag unusual invoice amounts, duplicate risk, and missing + fields. +01. `expense_audit_assistant` | `B1` | Review reimbursements for policy violations and evidence + gaps. +01. `loan_application_screening` | `B2` | Structure applicant packets and surface review priorities. +01. `aml_case_prioritizer` | `B3` | Rank anti-money-laundering cases by signal strength and analyst + value. +01. `insurance_claim_triage` | `B2` | Classify claim complexity, fraud indicators, and next-review + steps. +01. `portfolio_news_risk_monitor` | `B2` | Connect news events to holdings and summarize exposure + impact. +01. `vendor_risk_scanner` | `B2` | Combine public signals, contracts, and incidents into vendor risk + views. +01. `cashflow_forecaster` | `B2` | Blend invoicing, pipeline, and payment signals into short-term + forecasts. +01. `pricing_anomaly_monitor` | `B2` | Detect suspicious price changes across products or accounts. +01. `billing_dispute_resolver` | `B1` | Organize billing evidence and draft dispute-resolution + responses. ### Commerce And Supply Chain -81. `demand_sensing_assistant` | `B2` | Merge sales, promotions, weather, and events to improve demand sensing. -82. `dynamic_catalog_enricher` | `B1` | Normalize product listings and generate richer catalog attributes. -83. `return_reason_analyzer` | `B1` | Cluster return causes and identify avoidable quality or expectation gaps. -84. `supplier_delay_predictor` | `B2` | Forecast likely supplier delays from signals across orders and incidents. -85. `warehouse_pick_path_optimizer` | `B3` | Combine order waves and layout constraints for better pick routing. -86. `aftersales_ticket_triage` | `B1` | Prioritize installation, warranty, and repair tickets. -87. `menu_margin_copilot` | `B1` | Help restaurant operators balance menu pricing, margin, and demand. -88. `store_ops_digest` | `B1` | Summarize shift issues, stockouts, shrinkage, and action items. -89. `franchise_quality_monitor` | `B2` | Compare quality drift, service complaints, and compliance across stores. -90. `marketplace_listing_guard` | `B2` | Detect duplicate listings, policy risk, and misleading claims. +81. `demand_sensing_assistant` | `B2` | Merge sales, promotions, weather, and events to improve + demand sensing. +01. `dynamic_catalog_enricher` | `B1` | Normalize product listings and generate richer catalog + attributes. +01. `return_reason_analyzer` | `B1` | Cluster return causes and identify avoidable quality or + expectation gaps. +01. `supplier_delay_predictor` | `B2` | Forecast likely supplier delays from signals across orders + and incidents. +01. `warehouse_pick_path_optimizer` | `B3` | Combine order waves and layout constraints for better + pick routing. +01. `aftersales_ticket_triage` | `B1` | Prioritize installation, warranty, and repair tickets. +01. `menu_margin_copilot` | `B1` | Help restaurant operators balance menu pricing, margin, and + demand. +01. `store_ops_digest` | `B1` | Summarize shift issues, stockouts, shrinkage, and action items. +01. `franchise_quality_monitor` | `B2` | Compare quality drift, service complaints, and compliance + across stores. +01. `marketplace_listing_guard` | `B2` | Detect duplicate listings, policy risk, and misleading + claims. ### Sustainability And Infrastructure -91. `carbon_data_collector` | `B1` | Gather emissions data from documents, meters, and operational systems. -92. `solar_farm_alerting` | `B2` | Summarize inverter anomalies, weather effects, and maintenance needs. -93. `water_leak_monitor` | `B2` | Detect persistent leak signatures and prioritize field inspection. -94. `campus_emissions_reporter` | `B1` | Build periodic campus sustainability reports from distributed data. -95. `hvac_fault_diagnosis` | `B2` | Diagnose HVAC performance issues from building telemetry. -96. `grid_event_summarizer` | `B3` | Turn grid alarms and operator logs into concise incident timelines. -97. `recycling_sorting_analytics` | `B3` | Analyze contamination patterns in recycling streams using vision inputs. -98. `construction_site_safety_monitor` | `B3` | Detect unsafe behavior, zone conflicts, and repeated risk patterns. -99. `data_center_capacity_watch` | `B2` | Track capacity, cooling pressure, and incident risk in data centers. -100. `pipeline_anomaly_review` | `B3` | Review inspection imagery, sensor events, and maintenance history. +91. `carbon_data_collector` | `B1` | Gather emissions data from documents, meters, and operational + systems. +01. `solar_farm_alerting` | `B2` | Summarize inverter anomalies, weather effects, and maintenance + needs. +01. `water_leak_monitor` | `B2` | Detect persistent leak signatures and prioritize field inspection. +01. `campus_emissions_reporter` | `B1` | Build periodic campus sustainability reports from + distributed data. +01. `hvac_fault_diagnosis` | `B2` | Diagnose HVAC performance issues from building telemetry. +01. `grid_event_summarizer` | `B3` | Turn grid alarms and operator logs into concise incident + timelines. +01. `recycling_sorting_analytics` | `B3` | Analyze contamination patterns in recycling streams using + vision inputs. +01. `construction_site_safety_monitor` | `B3` | Detect unsafe behavior, zone conflicts, and repeated + risk patterns. +01. `data_center_capacity_watch` | `B2` | Track capacity, cooling pressure, and incident risk in + data centers. +01. `pipeline_anomaly_review` | `B3` | Review inspection imagery, sensor events, and maintenance + history. ## First 15 New Apps To Build @@ -170,24 +248,24 @@ If the goal is to move from planning to implementation with the highest signal a effort, these are the strongest next candidates after the already existing apps: 1. `meeting_action_tracker` -2. `course_qa_tutor` -3. `assignment_feedback_assistant` -4. `patent_landscape_mapper` -5. `grant_call_matcher` -6. `customer_support_copilot` -7. `org_policy_search` -8. `podcast_clip_generator` -9. `dynamic_catalog_enricher` -10. `return_reason_analyzer` -11. `carbon_data_collector` -12. `campus_emissions_reporter` -13. `drug_label_extractor` -14. `telehealth_visit_summarizer` -15. `citizen_feedback_router` +1. `course_qa_tutor` +1. `assignment_feedback_assistant` +1. `patent_landscape_mapper` +1. `grant_call_matcher` +1. `customer_support_copilot` +1. `org_policy_search` +1. `podcast_clip_generator` +1. `dynamic_catalog_enricher` +1. `return_reason_analyzer` +1. `carbon_data_collector` +1. `campus_emissions_reporter` +1. `drug_label_extractor` +1. `telehealth_visit_summarizer` +1. `citizen_feedback_router` ## Recommended Implementation Order 1. Finish stabilizing and testing the existing apps as reusable reference implementations. -2. Build the first 5 `B1` apps that mostly reuse current dependencies and packaging. -3. Extract shared retrieval, state, and operator utilities from those apps before starting `B2`. -4. Only start `B3` apps after the repo has enough stable multimodal and integration primitives. +1. Build the first 5 `B1` apps that mostly reuse current dependencies and packaging. +1. Extract shared retrieval, state, and operator utilities from those apps before starting `B2`. +1. Only start `B3` apps after the repo has enough stable multimodal and integration primitives. diff --git a/docs/customer-support-ticket-triage-plan.md b/docs/customer-support-ticket-triage-plan.md new file mode 100644 index 0000000..7a7c1af --- /dev/null +++ b/docs/customer-support-ticket-triage-plan.md @@ -0,0 +1,425 @@ +# 基于 SAGE 的客服工单分诊系统实现计划 + +说明:当前这个 Demo 的“实现计划 + 实际代码结构 + 运行方式”已经统一维护在 +[apps/src/sage/apps/ticket_triage/README.md](../apps/src/sage/apps/ticket_triage/README.md)。 + +本文现在保留为方案入口和背景说明;如果这里的内容与代码不一致,以 +[apps/src/sage/apps/ticket_triage/README.md](../apps/src/sage/apps/ticket_triage/README.md) 为准。 + +本文给出“客服工单分诊系统”这个 Demo 的落地方案。目标不是做一个只会分类的脚本,而是做一个显式调用 SAGE 接口、具备状态管理、可扩展为 API 服务的可运行演示应用。 + +## 1. 目标边界 + +这个软件第一版只解决五件事: + +1. 接收来自邮件、表单和在线聊天的工单输入。 +1. 识别工单主题、业务类型和紧急程度。 +1. 基于 FAQ 或历史案例推荐自动回复草案。 +1. 根据规则把工单路由到对应团队或人工坐席。 +1. 持续记录工单状态,输出结构化分诊结果。 + +第一版不做复杂前端,不直接接企业 CRM,也不追求多渠道实时接入。核心目标是把 SAGE 的以下能力演示出来: + +- 数据流式处理 +- 状态化工单跟踪 +- 明确可解释的算子链路 +- CLI Demo 和 API Demo 双形态输出 + +## 2. 必须调用的 SAGE 接口 + +这个项目必须显式调用 SAGE 接口,不能只写普通 Python 函数把结果拼起来。建议第一版固定使用以下接口。 + +### 2.1 核心运行时接口 + +```python +from sage.foundation import BatchFunction, MapFunction, SinkFunction +from sage.runtime import BaseService, LocalEnvironment +``` + +用途如下: + +- `BatchFunction`:作为工单事件源,批量读取演示邮件、表单和聊天消息。 +- `MapFunction`:实现解析、分类、打分、案例召回、路由决策、回复建议等步骤。 +- `SinkFunction`:汇总分诊结果,输出控制台摘要和结构化结果。 +- `BaseService`:封装工单状态仓和知识库索引,让算子通过显式服务调用读写状态。 +- `LocalEnvironment`:负责组织并执行整条 SAGE workflow。 + +### 2.2 建议保留的服务化接口 + +如果后续要把 Demo 暴露给网页或外部系统,建议保持与当前仓库其他示例一致的服务层结构: + +```python +from sage.runtime import LocalEnvironment +``` + +第一版先通过 `service.py` 封装应用服务门面,再由 FastAPI 或其他 HTTP 适配层调用服务层。也就是说,对外提供 API 没问题,但业务主干必须跑在 SAGE runtime +上,而不是绕过 SAGE 直接写控制器逻辑。 + +### 2.3 可选的 LLM 增强接口 + +如果第二版要支持更自然的自动回复或复杂工单解释,可以额外接入: + +```python +from sage.serving import SageServeConfig, probe_gateway +``` + +用途如下: + +- 使用 `SageServeConfig` 组织网关配置。 +- 使用 `probe_gateway` 在启动时显式检查模型网关是否可用。 +- 仅在网关检查通过时启用“自动回复润色”或“升级原因解释”。 + +这里要坚持 fail-fast:如果用户开启了 LLM 模式但网关不可用,直接给出明确错误,而不是静默降级。 + +## 3. 推荐目录结构 + +建议按照当前 `sage-examples/apps` 的组织方式新增一个应用目录: + +```text +apps/src/sage/apps/ticket_triage/ +├── __init__.py +├── models.py +├── demo_data.py +├── state_store.py +├── operators.py +├── workflow.py +├── service.py +└── README.md + +examples/ +└── run_ticket_triage.py + +apps/tests/ticket_triage/ +├── test_workflow.py +├── test_service.py +└── test_routing_rules.py +``` + +对应职责: + +- `models.py`:定义输入工单、分类结果、路由决策、回复草案、状态快照等模型。 +- `demo_data.py`:提供离线可运行的示例邮件、表单工单、聊天消息和 FAQ 数据。 +- `state_store.py`:保存工单状态、历史处理记录、知识库条目和路由统计。 +- `operators.py`:实现所有 SAGE 算子。 +- `workflow.py`:用 `LocalEnvironment + BaseService` 组织完整链路。 +- `service.py`:封装高层应用服务门面,供 CLI 或 HTTP API 调用。 +- `run_ticket_triage.py`:提供统一的演示入口。 + +## 4. 业务对象设计 + +第一版建议把模型控制在 10 个以内,重点突出“输入事件 - 分诊结果 - 状态快照”三层结构。 + +### 4.1 输入对象 + +建议定义以下输入模型: + +- `TicketEvent` +- `KnowledgeBaseArticle` +- `HistoricalResolution` + +其中 `TicketEvent` 建议包含这些字段: + +- `ticket_id` +- `channel`,例如 `email`、`form`、`chat` +- `customer_id` +- `subject` +- `message` +- `created_at` +- `attachments` +- `language` + +### 4.2 中间分析对象 + +建议定义: + +- `NormalizedTicket` +- `IntentClassification` +- `UrgencyScore` +- `SimilarCaseMatch` +- `RoutingDecision` +- `ReplyDraft` + +### 4.3 输出对象 + +建议定义: + +- `TicketTriageResult` +- `TicketStatusSnapshot` +- `TeamQueueSummary` + +## 5. 状态仓设计 + +这个项目天然适合做成“事件流 + 状态仓”。在 `state_store.py` 里建议维护以下索引: + +- 工单快照索引:按 `ticket_id` +- 客户最近工单索引:按 `customer_id` +- FAQ 与知识条目索引:按 `article_id` +- 历史案例索引:按 `intent` 或标签 +- 队列统计索引:按 `assigned_team` + +建议服务名: + +```python +STATE_SERVICE_NAME = "ticket_triage_state" +``` + +建议暴露给 SAGE 算子的服务方法: + +- `save_ticket_snapshot` +- `get_ticket_snapshot` +- `list_customer_recent_tickets` +- `search_knowledge_articles` +- `search_similar_resolutions` +- `append_triage_result` +- `assign_team_queue` +- `list_queue_summary` +- `build_status_snapshot` + +这个设计和当前仓库里学生提分示例的模式一致:由 `BaseService` 暴露明确的状态操作,再让 `MapFunction` 通过 `call_service(...)` 调用它们。 + +## 6. SAGE Pipeline 设计 + +### 6.1 第一版主链路 + +建议第一版采用单链路统一处理: + +```text +TicketSource + -> NormalizeTicketStep + -> EnrichCustomerContextStep + -> ClassifyIntentStep + -> ScoreUrgencyStep + -> RecallSimilarCasesStep + -> DecideRouteStep + -> DraftReplyStep + -> PersistTicketStateStep + -> ResultSink +``` + +每一步职责如下: + +#### `TicketSource(BatchFunction)` + +- 从 `demo_data.py` 输出演示工单。 +- 支持邮件、表单、聊天三种输入来源。 + +#### `NormalizeTicketStep(MapFunction)` + +- 统一字段结构。 +- 清理空白、标准化优先级关键词、提取摘要。 +- 生成 `NormalizedTicket`。 + +#### `EnrichCustomerContextStep(MapFunction)` + +- 通过 `call_service(STATE_SERVICE_NAME, ...)` 拉取用户最近工单和当前未关闭工单。 +- 为后续紧急度判断补充上下文。 + +#### `ClassifyIntentStep(MapFunction)` + +- 识别工单类型,例如账单、登录故障、物流咨询、退款申请、投诉升级。 +- 第一版优先用规则和关键词实现,确保稳定。 + +#### `ScoreUrgencyStep(MapFunction)` + +- 综合渠道、关键词、历史重复报障次数、客户等级等信息给出紧急度分数。 +- 输出 `UrgencyScore` 和说明原因。 + +#### `RecallSimilarCasesStep(MapFunction)` + +- 从 FAQ 和历史案例中召回最相似的处理记录。 +- 输出 `SimilarCaseMatch` 列表。 + +#### `DecideRouteStep(MapFunction)` + +- 根据意图类型、紧急度和相似案例决定: + - 自动回复 + - 转 L1 客服 + - 转技术支持 + - 转财务/订单团队 + - 升级到人工主管 +- 输出 `RoutingDecision`。 + +#### `DraftReplyStep(MapFunction)` + +- 先基于模板和召回结果生成自动回复草案。 +- 如果启用了 LLM 模式,再通过 SAGE serving 接口补做润色或说明生成。 +- 输出 `ReplyDraft`。 + +#### `PersistTicketStateStep(MapFunction)` + +- 通过 `call_service(STATE_SERVICE_NAME, ...)` 写回工单快照、队列归属和处理建议。 +- 输出最终 `TicketTriageResult`。 + +#### `ResultSink(SinkFunction)` + +- 收集结果对象。 +- 输出结构化 JSON 和控制台摘要。 + +### 6.2 推荐的最小工作流骨架 + +建议在 `workflow.py` 里采用这种结构: + +```python +from sage.foundation import MapFunction, SinkFunction +from sage.runtime import BaseService, LocalEnvironment + +STATE_SERVICE_NAME = "ticket_triage_state" + + +class TicketTriageStateService(BaseService): + def __init__(self, store): + super().__init__() + self.store = store + + +def run_ticket_triage(events, store): + results = [] + environment = LocalEnvironment("customer_support_ticket_triage") + environment.register_service(STATE_SERVICE_NAME, TicketTriageStateService, store) + ( + environment.from_batch(events) + .map(NormalizeTicketStep) + .map(EnrichCustomerContextStep) + .map(ClassifyIntentStep) + .map(ScoreUrgencyStep) + .map(RecallSimilarCasesStep) + .map(DecideRouteStep) + .map(DraftReplyStep) + .map(PersistTicketStateStep) + .sink(ResultCollectorSink, results=results) + ) + environment.submit(autostop=True) + return results +``` + +这段骨架要表达两个关键点: + +1. 整条处理链路运行在 `LocalEnvironment` 上。 +1. 状态读写必须通过 `register_service(...)` 和 `call_service(...)` 完成。 + +这样才算真正“调用了 SAGE 接口”。 + +## 7. 第一版建议实现的规则 + +第一版不要一开始就上复杂分类模型,先做可解释规则版。 + +建议优先实现以下 8 条规则: + +1. 消息中出现“无法登录”“账号锁定”“支付失败”等词,优先归为高优先级故障单。 +1. 消息中出现“退款”“重复扣费”“发票”时,路由到订单或财务队列。 +1. 消息中出现“投诉”“升级”“立即处理”“已经等待两天”时,提高紧急度。 +1. 同一客户 24 小时内重复提交相似工单时,提高一个优先级档位。 +1. 命中 FAQ 且置信度高于阈值时,生成自动回复建议。 +1. 未命中 FAQ 且工单复杂度高时,直接转人工。 +1. 高价值客户或 SLA 临近工单优先进入人工快速通道。 +1. 带附件且包含“报错截图”“账单截图”等字段时,标记为需要人工复核。 + +这些规则已经足够让 Demo 展示“意图识别 + 紧急度打分 + 路由决策 + 状态更新”四个核心能力。 + +## 8. Service 层设计 + +建议 `service.py` 对外只暴露少量稳定方法: + +- `ingest_tickets(events)`:批量导入并执行分诊。 +- `run_demo(reset=True)`:跑一组内置演示数据。 +- `get_ticket(ticket_id)`:获取单个工单状态。 +- `list_queue_summary()`:查看队列摘要。 +- `list_open_high_priority_tickets()`:查看高优先级工单。 + +这个服务层负责把 CLI、HTTP API 和 workflow 解耦。CLI 或 Web 层不直接操作算子,而是统一调用应用服务。 + +## 9. Demo 数据设计 + +建议第一版准备 12 到 20 条演示工单,覆盖以下场景: + +- 登录失败 +- 订单延迟 +- 退款申请 +- 重复扣费 +- 发票问题 +- 技术报错 +- 客诉升级 +- 多轮重复催办 + +每类场景至少准备: + +- 1 条可自动回复的简单工单 +- 1 条需要转人工的复杂工单 +- 1 条需要升级处理的高优先级工单 + +这样演示时能清楚展示不同分流路径。 + +## 10. 输出形态设计 + +第一版建议同时输出三类结果: + +1. 控制台摘要:展示工单编号、意图、优先级、路由团队、回复建议。 +1. JSON 结果:方便后续接 API 或前端。 +1. 队列快照:展示每个团队当前待处理工单数。 + +输出示例可以包含: + +- `ticket_id` +- `intent` +- `priority` +- `assigned_team` +- `recommended_action` +- `reply_draft` +- `reason_trace` + +其中 `reason_trace` 很重要,它可以明确告诉用户为什么这个工单被判定为高优先级、为什么被路由到某个团队,这正好体现 SAGE 链路可解释的优势。 + +## 11. 测试计划 + +建议至少补三类测试: + +### 11.1 Workflow 测试 + +- 给定一组演示工单,确认 workflow 能跑通。 +- 验证结果数量、优先级和路由字段存在。 + +### 11.2 规则测试 + +- 验证关键词命中后的意图分类。 +- 验证重复报障后的紧急度升级。 +- 验证 FAQ 命中后的自动回复推荐。 + +### 11.3 Service 测试 + +- 验证 `run_demo()` 输出结构正确。 +- 验证状态仓中能查到最新工单快照和队列汇总。 + +## 12. 实现顺序建议 + +建议按下面顺序实现: + +1. 先写 `models.py` 和 `demo_data.py`,保证输入输出结构清晰。 +1. 再写 `state_store.py` 和 `BaseService`,把状态操作接口固定下来。 +1. 实现 `NormalizeTicketStep`、`ClassifyIntentStep`、`ScoreUrgencyStep`、`DecideRouteStep` 四个核心算子。 +1. 在 `workflow.py` 中用 `LocalEnvironment` 把链路串起来。 +1. 在 `service.py` 里封装对外接口。 +1. 最后补 `examples/run_ticket_triage.py` 和测试。 + +这个顺序可以最快做出一个能跑通、能解释、能扩展的 MVP。 + +## 13. 第一版 MVP 验收标准 + +只要满足下面几点,就可以认为第一版完成: + +1. 能从演示数据批量导入工单。 +1. 能显式通过 SAGE workflow 完成分类、打分、路由和状态更新。 +1. 能输出结构化分诊结果和队列摘要。 +1. 能通过服务层重复查询工单状态。 +1. 代码结构与当前 `sage-examples` 仓库模式一致。 + +## 14. 后续可扩展方向 + +第一版跑通后,可以继续扩展: + +- 接入真正的 FAQ 检索或向量召回。 +- 引入 LLM 自动回复润色,但仍通过 SAGE serving 接口接入。 +- 增加 FastAPI 或 Web 控制台。 +- 增加 SLA 计时和超时预警。 +- 增加客服团队负载均衡与转派规则。 + +整体上,这个 Demo 很适合展示 SAGE 在“多来源输入、状态化分析、规则与模型结合、服务化输出”上的完整能力。 diff --git a/docs/sage-demo-app-ideas.md b/docs/sage-demo-app-ideas.md new file mode 100644 index 0000000..3c16ab3 --- /dev/null +++ b/docs/sage-demo-app-ideas.md @@ -0,0 +1,613 @@ +# 基于 SAGE 构建演示示例软件的 20 个代码思路 + +本文整理了 20 个适合用 SAGE 来构建的演示型软件方案,重点强调以下能力: + +- 以数据流为核心组织处理链路 +- 用显式算子拆分清洗、判断、聚合、生成逻辑 +- 用状态仓保存用户、任务、设备或业务对象的持续状态 +- 用本地运行时或服务化接口对外暴露可运行 Demo + +这些思路尽量贴近当前仓库已有的示例方向,例如医疗诊断、文章监测、智能家居、专利分析、学生提分和视频理解,但避免简单重复。 + +## 统一实现骨架 + +每个 Demo 都建议采用相似的目录与代码组织方式: + +1. `models.py`:定义输入记录、处理结果、状态对象、报告对象。 +1. `operators.py`:实现解析、特征抽取、分类、打分、聚合等 SAGE 算子。 +1. `pipeline.py` 或 `workflow.py`:用 `LocalEnvironment` 或相关运行时组织完整链路。 +1. `service.py`:封装应用服务门面,必要时导出 HTTP API。 +1. `demo_data.py`:提供最小可跑通的示例数据。 +1. `run_xxx.py`:提供命令行演示入口。 + +______________________________________________________________________ + +## 1. 智能招投标助手 + +**目标** + +- 自动拆解招标书要求 +- 匹配企业资质与历史案例 +- 输出风险项、材料清单和建议时间线 + +**输入流** + +- 招标文档 +- 企业资质文档 +- 历史中标案例 + +**核心算子** + +- 文档分段算子 +- 条款抽取算子 +- 资质匹配算子 +- 缺口分析算子 +- 风险汇总算子 + +**输出** + +- 投标风险报告 +- 待补充材料清单 +- 项目里程碑建议 + +## 2. 客服工单分诊系统 + +**目标** + +- 自动识别工单类型与优先级 +- 推荐自动回复或转人工路径 +- 持续跟踪工单状态变化 + +**输入流** + +- 邮件 +- 表单工单 +- 在线聊天消息 +- FAQ 或知识库 + +**核心算子** + +- 意图识别算子 +- 紧急度评分算子 +- 相似案例召回算子 +- 路由决策算子 +- 状态更新算子 + +**输出** + +- 工单标签 +- 处理优先级 +- 自动回复建议 +- 升级路径 + +详细实现计划见 [docs/customer-support-ticket-triage-plan.md](docs/customer-support-ticket-triage-plan.md)。 + +## 3. 合同审阅与修订建议器 + +**目标** + +- 自动识别合同风险条款 +- 标注缺失项与不一致项 +- 输出修订建议 + +**输入流** + +- 合同正文 +- 企业法务规则 +- 条款模板库 + +**核心算子** + +- 条款切分算子 +- 风险分类算子 +- 红线规则校验算子 +- 模板比对算子 +- 审阅摘要生成算子 + +**输出** + +- 合同审阅意见 +- 风险等级标签 +- 建议替换条款 + +## 4. 代码变更影响分析助手 + +**目标** + +- 依据代码变更推导影响模块 +- 推荐回归测试范围 +- 输出潜在风险说明 + +**输入流** + +- Git diff +- 模块依赖关系 +- 历史缺陷记录 +- 测试清单 + +**核心算子** + +- Diff 解析算子 +- 依赖扩散算子 +- 风险评分算子 +- 测试推荐算子 +- 变更摘要算子 + +**输出** + +- 影响模块列表 +- 回归测试建议 +- 风险报告 + +## 5. 会议纪要行动项执行器 + +**目标** + +- 从会议内容中抽取决策与待办 +- 识别责任人与截止时间 +- 跟踪事项执行状态 + +**输入流** + +- 会议转写文本 +- 参会人员信息 +- 项目背景资料 + +**核心算子** + +- 议题切分算子 +- 决策抽取算子 +- 责任人识别算子 +- 时间信息抽取算子 +- 行动项聚合算子 + +**输出** + +- 结构化会议纪要 +- 行动项列表 +- 执行状态面板数据 + +## 6. 销售线索评分与跟进建议系统 + +**目标** + +- 动态更新销售线索质量评分 +- 结合客户行为生成下一步跟进建议 + +**输入流** + +- CRM 线索记录 +- 通话摘要 +- 邮件互动 +- 官网行为日志 + +**核心算子** + +- 客户画像更新算子 +- 购买意向评分算子 +- 相似客户匹配算子 +- 跟进建议生成算子 + +**输出** + +- 线索热度分数 +- 推荐跟进动作 +- 风险客户提醒 + +## 7. 供应链异常预警看板 + +**目标** + +- 检测订单、库存、物流中的异常 +- 评估延迟影响范围 +- 推荐替代方案 + +**输入流** + +- 采购订单 +- 库存变更记录 +- 物流轨迹 +- 供应商履约记录 + +**核心算子** + +- 异常检测算子 +- 延误归因算子 +- 影响评估算子 +- 替代供应商推荐算子 + +**输出** + +- 风险预警列表 +- 影响范围摘要 +- 调整建议 + +详细实现计划见 [docs/supply-chain-alert-board-plan.md](docs/supply-chain-alert-board-plan.md)。 + +## 8. 制造设备预测性维护助手 + +**目标** + +- 从传感器与巡检数据中发现潜在故障 +- 提前输出维护建议 + +**输入流** + +- 传感器时序 +- 巡检记录 +- 故障历史 +- 设备手册 + +**核心算子** + +- 时序清洗算子 +- 特征提取算子 +- 异常模式识别算子 +- 维护工单生成算子 + +**输出** + +- 故障风险评分 +- 维护建议 +- 工单草稿 + +## 9. 智能安防巡检平台 + +**目标** + +- 联合分析画面事件与门禁记录 +- 对异常行为进行去重与聚合 + +**输入流** + +- 摄像头截图或视频片段 +- 门禁日志 +- 巡逻记录 +- 告警规则配置 + +**核心算子** + +- 视觉事件抽取算子 +- 门禁异常检测算子 +- 时空关联算子 +- 告警聚合算子 + +**输出** + +- 异常事件时间线 +- 告警摘要 +- 人工复核队列 + +## 10. 医院门诊分诊与检查建议助手 + +**目标** + +- 根据主诉和基础检查结果辅助分诊 +- 生成初步检查建议 + +**输入流** + +- 主诉文本 +- 生命体征 +- 初始检验结果 +- 病史信息 + +**核心算子** + +- 症状标准化算子 +- 危险等级分层算子 +- 科室路由算子 +- 检查建议生成算子 + +**输出** + +- 分诊建议 +- 检查项目列表 +- 风险提示 + +## 11. 医学影像随访变化对比工具 + +**目标** + +- 对比同一患者多期影像与报告 +- 生成随访变化摘要 + +**输入流** + +- 多次影像结果 +- 历史报告 +- 患者档案 + +**核心算子** + +- 病例对齐算子 +- 时间序列对比算子 +- 区域变化摘要算子 +- 随访报告生成算子 + +**输出** + +- 变化摘要 +- 随访建议 +- 重点关注指标 + +## 12. 临床试验文献筛选助手 + +**目标** + +- 从大量论文中筛出符合纳排标准的研究 +- 解释每篇论文为何入选或排除 + +**输入流** + +- 文献摘要 +- 试验纳排标准 +- 药物或疾病词表 + +**核心算子** + +- 文献召回算子 +- 标准匹配算子 +- 理由解释算子 +- 优先级排序算子 + +**输出** + +- 候选论文列表 +- 纳排判断依据 +- 推荐阅读顺序 + +## 13. 教学课堂反馈诊断平台 + +**目标** + +- 从班级作业和测验中总结整体薄弱点 +- 生成教师干预建议 + +**输入流** + +- 作业数据 +- 小测成绩 +- 教师评语 +- 教学大纲 + +**核心算子** + +- 知识点映射算子 +- 错因分类算子 +- 班级掌握度聚合算子 +- 干预建议生成算子 + +**输出** + +- 班级薄弱点热力图 +- 学生分层建议 +- 课后补救计划 + +## 14. 企业培训与能力图谱助手 + +**目标** + +- 将岗位要求与员工学习记录结合 +- 生成能力缺口与培训路径 + +**输入流** + +- 岗位能力模型 +- 员工测评记录 +- 学习记录 +- 课程目录 + +**核心算子** + +- 能力项映射算子 +- 缺口计算算子 +- 路径规划算子 +- 培训推荐算子 + +**输出** + +- 能力图谱 +- 培训优先级 +- 个性化学习计划 + +## 15. 研发论文到专利机会发现器 + +**目标** + +- 连接前沿论文与现有专利布局 +- 识别可申请的潜在技术方向 + +**输入流** + +- 最新论文 +- 企业已有专利 +- 竞品专利库 +- 技术关键词 + +**核心算子** + +- 主题聚类算子 +- 技术映射算子 +- 空白机会识别算子 +- 机会评分算子 + +**输出** + +- 技术机会列表 +- 潜在专利方向摘要 +- 竞争态势说明 + +## 16. ESG 舆情与合规监测助手 + +**目标** + +- 持续监测企业 ESG 相关舆情与合规风险 +- 自动生成风险事件摘要 + +**输入流** + +- 新闻资讯 +- 公告内容 +- 社交媒体摘要 +- 监管规则文档 + +**核心算子** + +- 主题归类算子 +- 负面事件识别算子 +- 法规映射算子 +- 风险时间线聚合算子 + +**输出** + +- ESG 风险清单 +- 事件时间线 +- 合规关注点报告 + +## 17. 智能地产选址分析器 + +**目标** + +- 对目标区域做商业适配性评估 +- 生成门店选址建议 + +**输入流** + +- 区域 POI +- 交通数据 +- 人口画像 +- 竞品门店信息 +- 租金数据 + +**核心算子** + +- 区域特征提取算子 +- 客群匹配算子 +- 竞品密度分析算子 +- 综合评分算子 + +**输出** + +- 区域评分排行 +- 选址建议 +- 竞争风险说明 + +## 18. 智慧园区能耗优化系统 + +**目标** + +- 监测楼宇能耗异常 +- 推荐节能策略和调度建议 + +**输入流** + +- 能耗记录 +- 设备运行状态 +- 电价时段 +- 天气数据 + +**核心算子** + +- 能耗异常检测算子 +- 峰值预测算子 +- 成本估算算子 +- 节能方案推荐算子 + +**输出** + +- 异常能耗清单 +- 节能建议 +- 成本优化估算 + +## 19. 跨境电商商品上架助手 + +**目标** + +- 自动生成商品标题、卖点与属性 +- 检查平台规则风险 + +**输入流** + +- 商品图片 +- 商品描述 +- 平台规则 +- 竞品标题与价格 + +**核心算子** + +- 属性抽取算子 +- 文案生成算子 +- 违规规则校验算子 +- 价格带分析算子 + +**输出** + +- 上架文案 +- 属性表单 +- 风险提示 +- 定价建议 + +## 20. 财务票据与报销审计助手 + +**目标** + +- 对发票和报销单做结构化审计 +- 自动识别违规、重复和超预算问题 + +**输入流** + +- 发票图片 +- 报销单 +- 制度规则 +- 预算信息 + +**核心算子** + +- OCR 结构化算子 +- 规则校验算子 +- 重复报销检测算子 +- 预算占用聚合算子 +- 审计摘要生成算子 + +**输出** + +- 报销审计结果 +- 异常项列表 +- 预算使用摘要 + +______________________________________________________________________ + +## 最适合优先实现的 5 个方向 + +如果要从演示效果、实现成本和业务表达力三个维度综合考虑,建议优先做下面 5 个: + +1. 客服工单分诊系统 +1. 会议纪要行动项执行器 +1. 供应链异常预警看板 +1. 教学课堂反馈诊断平台 +1. 财务票据与报销审计助手 + +这几个方向的共同优点是: + +- 数据来源比较容易模拟 +- 不强依赖大型训练数据集 +- 容易体现 SAGE 的流式处理、状态管理和可解释链路 +- 适合快速做出 CLI Demo 或 API Demo + +## 推荐的最小 Demo 落地方式 + +每个项目第一版可以只做四步: + +1. 准备一个小型 `demo_data.py`,保证离线可运行。 +1. 用 3 到 5 个算子串出主流程,先把核心价值跑通。 +1. 提供一个 `examples/run_xxx.py` 作为统一入口。 +1. 输出结构化 JSON 和控制台摘要,先不急着做前端。 + +## 后续可扩展方向 + +- 增加可视化 dashboard +- 接入 OpenAI-compatible 模型服务 +- 接入数据库或共享状态服务 +- 接入外部 API 和实时事件源 +- 为每个 Demo 增加对应测试与 README diff --git a/docs/supply-chain-alert-board-plan.md b/docs/supply-chain-alert-board-plan.md new file mode 100644 index 0000000..c1c1d96 --- /dev/null +++ b/docs/supply-chain-alert-board-plan.md @@ -0,0 +1,453 @@ +# 基于 SAGE 的供应链异常预警看板实现计划 + +本文给出“供应链异常预警看板”这个 Demo 的落地方案,重点不是只做一个静态报表,而是做一个真正基于 SAGE 数据流和状态服务的可运行演示应用。 + +## 1. 目标边界 + +这个软件第一版只解决四件事: + +1. 接收供应链事件数据。 +1. 识别订单、库存、物流、供应商履约中的异常。 +1. 评估异常对订单履约和库存安全的影响。 +1. 生成看板摘要、风险预警和处置建议。 + +第一版不追求真实 ERP 对接,不做复杂前端,也不做企业级权限系统。核心目标是把 SAGE 的以下能力演示出来: + +- 数据流处理 +- 状态化分析 +- 明确的可解释算子链路 +- 可扩展为 API 服务 + +## 2. 必须调用的 SAGE 接口 + +这个项目必须显式调用 SAGE 接口,不能只写普通 Python 脚本后把结果打印出来。建议第一版固定使用以下接口: + +### 2.1 核心运行时接口 + +```python +from sage.foundation import BatchFunction, MapFunction, SinkFunction +from sage.runtime import BaseService, LocalEnvironment +``` + +用途如下: + +- `BatchFunction`:作为事件源,批量读取演示订单、库存、物流、供应商数据。 +- `MapFunction`:实现清洗、异常检测、影响评估、处置建议等处理步骤。 +- `SinkFunction`:收集预警结果和看板快照。 +- `BaseService`:挂载状态仓服务,给各个算子读写共享业务状态。 +- `LocalEnvironment`:组织并执行整条 SAGE pipeline。 + +### 2.2 可选的扩展运行时接口 + +```python +from sage.runtime import FlowNetEnvironment +``` + +用途如下: + +- 第二版或第三版需要模拟更高吞吐量时,再把本地运行切到 FlowNet-first 的分布式模式。 +- 第一版建议不要直接上分布式,先用 `LocalEnvironment` 把链路跑通。 + +### 2.3 可选的 SAGE 网关接口 + +如果要做 LLM 增强版的异常解释或处置建议,可以接入: + +```python +from sage.serving import SageServeConfig, probe_gateway +``` + +用途如下: + +- 先用 `SageServeConfig` 生成网关配置。 +- 再用 `probe_gateway` 探测外部 OpenAI-compatible 网关是否可用。 +- 网关健康时,再启用“风险解释生成”或“采购协调建议生成”。 + +也就是说,这个应用的主干一定是 SAGE runtime;LLM 只是挂在后面的增强能力。 + +## 3. 推荐目录结构 + +建议按照当前 `sage-examples/apps` 里的模式落地到一个新应用目录: + +```text +apps/src/sage/apps/supply_chain_alert/ +├── __init__.py +├── models.py +├── demo_data.py +├── state_store.py +├── operators.py +├── workflow.py +├── service.py +└── README.md + +examples/ +└── run_supply_chain_alert.py + +apps/tests/supply_chain_alert/ +├── test_workflow.py +├── test_service.py +└── test_detection.py +``` + +对应职责: + +- `models.py`:事件、风险、影响、建议、看板快照的数据结构。 +- `demo_data.py`:构造离线可跑的订单、库存、物流、供应商演示数据。 +- `state_store.py`:保存当前库存、未完成订单、物流状态、供应商风险分数。 +- `operators.py`:SAGE 算子实现。 +- `workflow.py`:基于 `LocalEnvironment + BaseService` 组织完整工作流。 +- `service.py`:高层应用服务门面,向 CLI 或 FastAPI 暴露接口。 +- `run_supply_chain_alert.py`:直接运行 Demo。 + +## 4. 业务对象设计 + +第一版建议先把数据对象控制在 8 个以内,避免模型层太重。 + +### 4.1 输入事件模型 + +建议定义以下输入对象: + +- `PurchaseOrderEvent` +- `InventoryEvent` +- `ShipmentEvent` +- `SupplierPerformanceEvent` + +字段示例: + +- 订单:订单号、SKU、数量、承诺交期、仓库、供应商 +- 库存:SKU、仓库、现有库存、安全库存、更新时间 +- 物流:运单号、订单号、当前位置、状态、预计到达时间、延迟小时数 +- 供应商:供应商 ID、准时率、缺陷率、过去 30 天违约次数 + +### 4.2 中间分析对象 + +建议定义: + +- `NormalizedSupplyEvent` +- `AnomalySignal` +- `ImpactAssessment` +- `MitigationSuggestion` + +### 4.3 看板输出对象 + +建议定义: + +- `OpenAlert` +- `DashboardSnapshot` +- `SupplierRiskSummary` + +## 5. 状态仓设计 + +这个项目适合做成“事件流 + 状态仓”的结构,而不是一次性离线计算。建议在 `state_store.py` 中维护: + +- 当前库存索引:按 `warehouse + sku` +- 未关闭订单索引:按 `order_id` +- 物流状态索引:按 `shipment_id` +- 供应商表现索引:按 `supplier_id` +- 当前开放告警列表:按 `alert_id` + +建议服务名: + +```python +STATE_SERVICE_NAME = "supply_chain_state" +``` + +建议暴露给 SAGE 算子的服务方法: + +- `upsert_inventory` +- `upsert_purchase_order` +- `upsert_shipment` +- `upsert_supplier_performance` +- `get_inventory_snapshot` +- `get_open_orders` +- `get_shipment` +- `get_supplier_profile` +- `save_alert` +- `list_open_alerts` +- `build_dashboard_snapshot` + +这一层直接复用当前仓库里“学生提分”示例的模式最稳:由 `BaseService` 暴露状态操作,MapFunction 用 `call_service(...)` 读写状态。 + +## 6. SAGE Pipeline 设计 + +### 6.1 第一版主链路 + +建议第一版采用单链路、统一输入事件的方式: + +```text +DemoEventSource + -> NormalizeEventStep + -> UpdateStateStep + -> DetectAnomalyStep + -> AssessImpactStep + -> RecommendMitigationStep + -> AlertSink +``` + +每一步职责如下: + +#### `DemoEventSource(BatchFunction)` + +- 从 `demo_data.py` 按时间顺序吐出演示事件。 +- 输出订单、库存、物流、供应商四类混合事件。 + +#### `NormalizeEventStep(MapFunction)` + +- 把不同来源事件转成统一的 `NormalizedSupplyEvent`。 +- 处理缺省值、时间格式和枚举映射。 + +#### `UpdateStateStep(MapFunction)` + +- 通过 `call_service(STATE_SERVICE_NAME, ...)` 更新库存、订单和物流状态。 +- 让后续算子总是基于最新状态做判断。 + +#### `DetectAnomalyStep(MapFunction)` + +- 检测以下异常: + - 库存低于安全阈值 + - 订单即将到期但未发货 + - 物流延迟超阈值 + - 供应商近期履约恶化 +- 输出一个或多个 `AnomalySignal`。 + +#### `AssessImpactStep(MapFunction)` + +- 估算异常影响范围: + - 影响订单数 + - 影响 SKU 数 + - 预计缺货天数 + - 风险等级 +- 输出 `ImpactAssessment`。 + +#### `RecommendMitigationStep(MapFunction)` + +- 结合当前库存和供应商表现,生成处置建议: + - 加急补货 + - 转仓调拨 + - 切换替代供应商 + - 调整承诺交期 +- 输出 `MitigationSuggestion`。 + +#### `AlertSink(SinkFunction)` + +- 汇总开放告警。 +- 生成控制台摘要和结构化 JSON 输出。 + +### 6.2 推荐的最小工作流骨架 + +建议在 `workflow.py` 中采用这种结构: + +```python +from sage.foundation import MapFunction, SinkFunction +from sage.runtime import BaseService, LocalEnvironment +``` + +工作流 runner 负责: + +1. 构建 `LocalEnvironment("supply_chain_alert")` +1. 注册 `SupplyChainStateService` +1. 通过 `environment.from_batch(...)` 启动事件源 +1. 串接多个 `.map(...)` +1. 用 `.sink(...)` 收集结果 +1. 调用 `environment.submit(autostop=True)` 执行 + +这部分建议严格对齐当前仓库里 +[sage-examples/apps/src/sage/apps/student_improvement/workflow.py](sage-examples/apps/src/sage/apps/student_improvement/workflow.py) +的模式。 + +## 7. 第一版建议实现的异常规则 + +第一版不要一开始就做复杂机器学习,先做规则版,确保演示稳定。 + +建议最先实现这 6 条规则: + +1. 安全库存不足:现有库存 < 安全库存。 +1. 在途延迟:物流预计到达时间晚于承诺到货时间。 +1. 发货停滞:运单状态超过设定小时数未变化。 +1. 订单积压:未发货订单数量超过阈值。 +1. 单一供应商过度依赖:关键 SKU 的供应集中度过高。 +1. 供应商风险恶化:准时率下降且违约次数上升。 + +这些规则都很适合拆成显式 `MapFunction` 逻辑,演示时也容易解释“为什么触发”。 + +## 8. 看板指标设计 + +第一版控制在 8 到 10 个核心指标即可: + +- 开放预警总数 +- 高风险预警数 +- 即将缺货 SKU 数 +- 延迟运单数 +- 超期未发货订单数 +- 高风险供应商数 +- 平均延迟小时数 +- 建议调拨次数 +- 建议替代采购次数 + +建议在 `DashboardSnapshot` 中一次性输出这些统计,便于 CLI 和 API 统一使用。 + +## 9. 应用服务层设计 + +建议做一个高层服务门面: + +```text +SupplyChainAlertApplicationService +``` + +建议提供的方法: + +- `run_demo()`:导入整套演示事件并返回最终看板快照 +- `ingest_events(events)`:接收一批外部事件并运行 pipeline +- `get_dashboard()`:获取当前看板摘要 +- `list_open_alerts()`:查看当前开放预警 +- `get_supplier_risk_summary()`:查看供应商风险画像 + +### 9.1 FastAPI 可选接口 + +如果要像学生提分示例一样提供 API,可加: + +- `POST /events/ingest` +- `GET /dashboard` +- `GET /alerts/open` +- `GET /suppliers/risk` +- `POST /demo/reset-and-run` + +这部分建议参考 +[sage-examples/apps/src/sage/apps/student_improvement/service.py](sage-examples/apps/src/sage/apps/student_improvement/service.py) +的做法。 + +## 10. LLM 增强点,且要通过 SAGE 网关接口接入 + +如果你希望 Demo 更像“智能预警看板”,可以加一个可选步骤:风险解释增强。 + +### 10.1 使用方式 + +先探测 SAGE 网关: + +```python +from sage.serving import SageServeConfig, probe_gateway + +gateway_config = SageServeConfig() +probe = probe_gateway(gateway_config) +``` + +只有当 `probe.ok` 为真时,才开启以下能力: + +- 把规则触发结果转成自然语言风险摘要 +- 生成采购协调建议 +- 生成给运营团队的日报摘要 + +### 10.2 注意点 + +- 不要把整个应用逻辑依赖到 LLM 上。 +- 规则预警必须在没有 LLM 的情况下也能独立运行。 +- LLM 只负责“解释”和“润色”,不负责底层告警判定。 + +这样可以保证 Demo 稳定,同时满足“调用 SAGE 接口”的要求。 + +## 11. 推荐的开发分阶段计划 + +### 阶段 1:离线 MVP + +目标:1 到 2 天内可跑通。 + +实现内容: + +- `models.py` +- `demo_data.py` +- `state_store.py` +- `operators.py` +- `workflow.py` +- `examples/run_supply_chain_alert.py` + +验收标准: + +- 运行一次脚本能输出看板摘要 +- 至少能触发 3 类不同异常 +- 输出结构化 JSON 和控制台摘要 + +### 阶段 2:服务化 + +目标:把 Demo 包装成可查询的应用。 + +实现内容: + +- `service.py` +- FastAPI app factory +- 查询开放预警和供应商风险接口 + +验收标准: + +- 可以通过 API 获取 dashboard 和 alerts +- 重复导入事件后状态能持续更新 + +### 阶段 3:智能解释增强 + +目标:给预警增加管理层可读性。 + +实现内容: + +- 接入 `SageServeConfig` +- 用 `probe_gateway` 做网关探测 +- 增加可选的摘要解释模块 + +验收标准: + +- 网关可用时返回自然语言解释 +- 网关不可用时应用仍能正常运行 + +### 阶段 4:规模化模拟 + +目标:演示更大规模事件处理。 + +实现内容: + +- 增加更长时间窗口的事件流 +- 视需要切换到 `FlowNetEnvironment` + +验收标准: + +- 能稳定处理更大规模模拟事件 +- 结果和本地版规则保持一致 + +## 12. 建议先写的测试 + +第一批测试建议如下: + +1. 低库存是否能正确触发预警。 +1. 延迟物流是否能正确计算影响等级。 +1. 替代供应商推荐是否按风险分数排序。 +1. 状态仓是否能在多批事件导入后保持一致。 +1. 没有网关时,LLM 增强分支是否自动关闭。 + +## 13. 推荐的演示脚本表现形式 + +为了让 Demo 更像产品,不建议只打印原始对象,建议输出三段: + +### 第一段:运行配置 + +- 模拟仓库数量 +- 供应商数量 +- 订单数量 +- 事件总数 + +### 第二段:实时预警摘要 + +- 当前事件类型 +- 触发规则 +- 风险等级 +- 建议动作 + +### 第三段:最终看板 + +- 风险总览 +- 影响订单总数 +- Top 风险供应商 +- Top 缺货 SKU + +## 14. 一句话总结实现路线 + +最稳的实现路线是: + +用 `BatchFunction` 构造供应链事件流,用 `MapFunction` 做标准化、状态更新、异常检测、影响评估和建议生成,用 `SinkFunction` 输出看板结果,用 +`BaseService` 持有共享状态,用 `LocalEnvironment` 执行主链路,再在可选阶段通过 `SageServeConfig + probe_gateway` 接入 SAGE +网关做风险解释增强。 diff --git a/examples/README.md b/examples/README.md index aaa1c9e..049d3e9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,12 @@ This directory contains maintained entry scripts for the `sage.apps` package. - `run_work_report.py` — Work report generation demo. - `run_patent_landscape_mapper.py` — Patent clustering and whitespace opportunity mapping demo. - `run_student_improvement.py` — Personalized score improvement system MVP. +- `run_supply_chain_alert.py` — Supply chain anomaly alert dashboard MVP. +- `run_supply_chain_alert_api.py` — FastAPI service for the supply chain anomaly alert dashboard, + including a multi-role browser dashboard at `/dashboard/ui`. +- `run_ticket_triage.py` — Customer support ticket triage MVP with explicit SAGE workflow and queue + routing. +- `run_ticket_triage_api.py` — FastAPI service for the customer support ticket triage MVP. - `demo_feature_extraction.py` — Feature extraction demo for medical imaging. ## Run Examples @@ -29,6 +35,10 @@ python examples/run_literature_report_assistant.py python examples/run_work_report.py python examples/run_patent_landscape_mapper.py python examples/run_student_improvement.py +python examples/run_supply_chain_alert.py +python examples/run_supply_chain_alert_api.py +python examples/run_ticket_triage.py +python examples/run_ticket_triage_api.py python examples/demo_feature_extraction.py ``` diff --git a/examples/run_patent_landscape_mapper.py b/examples/run_patent_landscape_mapper.py index 8b25455..8b2d617 100644 --- a/examples/run_patent_landscape_mapper.py +++ b/examples/run_patent_landscape_mapper.py @@ -8,7 +8,6 @@ import sys from pathlib import Path - PROJECT_ROOT = Path(__file__).resolve().parent.parent APPS_SRC = PROJECT_ROOT / "apps" / "src" if str(APPS_SRC) not in sys.path: @@ -80,4 +79,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/run_student_improvement.py b/examples/run_student_improvement.py index 22be591..6718de6 100644 --- a/examples/run_student_improvement.py +++ b/examples/run_student_improvement.py @@ -59,4 +59,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/examples/run_supply_chain_alert.py b/examples/run_supply_chain_alert.py new file mode 100644 index 0000000..a5b3868 --- /dev/null +++ b/examples/run_supply_chain_alert.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Run the supply chain alert dashboard demo.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from sage.apps.supply_chain_alert import SupplyChainAlertApplicationService +except ImportError as exc: + print(f"Error importing sage.apps.supply_chain_alert: {exc}") + print("\nPlease install sage-apps first:") + print(" cd apps && python -m pip install -e .") + sys.exit(1) + + +def print_console_report(result, demo_summary: dict[str, int]) -> None: + print("=" * 76) + print("SAGE Supply Chain Alert Dashboard") + print("=" * 76) + print("运行配置") + print("-" * 76) + print(f"模拟仓库数量: {demo_summary['warehouse_count']}") + print(f"供应商数量: {demo_summary['supplier_count']}") + print(f"订单数量: {demo_summary['order_count']}") + print(f"事件总数: {demo_summary['event_count']}") + print() + + print("实时预警摘要") + print("-" * 76) + for alert in result.alerts: + print(f"[{alert.risk_level.upper()}] {alert.title}") + print(f" 规则: {alert.rule_id}") + print(f" 摘要: {alert.summary}") + print(f" 建议: {'; '.join(alert.recommended_actions)}") + print() + + dashboard = result.dashboard + print("最终看板") + print("-" * 76) + print(f"开放预警总数: {dashboard.open_alert_count}") + print(f"高风险预警数: {dashboard.high_risk_alert_count}") + print(f"即将缺货 SKU 数: {dashboard.low_stock_sku_count}") + print(f"延迟运单数: {dashboard.delayed_shipment_count}") + print(f"超期未发货订单数: {dashboard.overdue_order_count}") + print(f"高风险供应商数: {dashboard.high_risk_supplier_count}") + print(f"平均延迟小时数: {dashboard.average_delay_hours}") + print(f"Top 风险供应商: {', '.join(item.supplier_id for item in dashboard.top_risk_suppliers)}") + print(f"Top 缺货 SKU: {', '.join(dashboard.top_shortage_skus)}") + + +def print_explanation_report(explanation_result) -> None: + print() + print("自然语言风险解释") + print("-" * 76) + gateway_status = explanation_result.gateway_status + if not gateway_status.reachable: + print("SAGE gateway 当前不可用,未生成自然语言解释。") + print(f"Health URL: {gateway_status.health_url}") + if gateway_status.error: + print(f"原因: {gateway_status.error}") + return + + if gateway_status.error: + print("SAGE gateway 已探测到,但解释生成失败。") + print(f"原因: {gateway_status.error}") + return + + print(f"Gateway Base URL: {gateway_status.base_url}") + if gateway_status.model: + print(f"使用模型: {gateway_status.model}") + if explanation_result.dashboard_summary: + print(f"管理摘要: {explanation_result.dashboard_summary}") + for item in explanation_result.alert_explanations: + print() + print(f"[{item.risk_level.upper()}] {item.title}") + print(f" {item.explanation}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="SAGE Supply Chain Alert Dashboard", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python examples/run_supply_chain_alert.py + python examples/run_supply_chain_alert.py --json + python examples/run_supply_chain_alert.py --explain + python examples/run_supply_chain_alert.py --storage-path .sage/supply-chain-alert.json + """, + ) + parser.add_argument( + "--storage-path", + type=Path, + default=None, + help="Optional JSON state path for persisting supply chain state.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON output instead of a console summary.", + ) + parser.add_argument( + "--explain", + action="store_true", + help="Generate optional natural-language risk explanations via sage.serving.", + ) + args = parser.parse_args() + + service = SupplyChainAlertApplicationService(storage_path=args.storage_path) + result = service.run_demo(reset=True) + explanation_result = service.explain_current_risks() if args.explain else None + + if args.json: + payload = {"result": result.to_dict()} + if explanation_result is not None: + payload["explanations"] = explanation_result.to_dict() + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return + + print_console_report(result, service.get_demo_summary()) + if explanation_result is not None: + print_explanation_report(explanation_result) + + +if __name__ == "__main__": + main() diff --git a/examples/run_supply_chain_alert_api.py b/examples/run_supply_chain_alert_api.py new file mode 100644 index 0000000..c6c4951 --- /dev/null +++ b/examples/run_supply_chain_alert_api.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run the supply chain alert FastAPI service.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +try: + import uvicorn + + from sage.apps.supply_chain_alert import ( + SupplyChainAlertApplicationService, + create_fastapi_app, + ) +except ImportError as exc: + print(f"Error importing supply chain alert API dependencies: {exc}") + print("\nPlease install sage-apps dependencies in your active environment.") + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run the SAGE Supply Chain Alert FastAPI service", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python examples/run_supply_chain_alert_api.py + python examples/run_supply_chain_alert_api.py --host 0.0.0.0 --port 8010 + python examples/run_supply_chain_alert_api.py --storage-path .sage/supply-chain-alert.json + """, + ) + parser.add_argument("--host", default="127.0.0.1", help="Host to bind the API server.") + parser.add_argument("--port", type=int, default=8000, help="Port to bind the API server.") + parser.add_argument( + "--storage-path", + type=Path, + default=None, + help="Optional JSON state path for persisting supply chain state.", + ) + args = parser.parse_args() + + service = SupplyChainAlertApplicationService(storage_path=args.storage_path) + app = create_fastapi_app(service) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/examples/run_ticket_triage.py b/examples/run_ticket_triage.py new file mode 100644 index 0000000..5ca503f --- /dev/null +++ b/examples/run_ticket_triage.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Run the customer support ticket triage demo.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from sage.apps.ticket_triage import TicketTriageApplicationService +except ImportError as exc: + print(f"Error importing sage.apps.ticket_triage: {exc}") + print("\nPlease install sage-apps first:") + print(" cd apps && python -m pip install -e .") + sys.exit(1) + + +def print_console_report(result, demo_summary: dict[str, int]) -> None: + print("=" * 76) + print("SAGE Customer Support Ticket Triage") + print("=" * 76) + print("运行配置") + print("-" * 76) + print(f"模拟工单数: {demo_summary['ticket_count']}") + print(f"渠道数: {demo_summary['channel_count']}") + print(f"客户数: {demo_summary['customer_count']}") + print(f"FAQ 条目数: {demo_summary['knowledge_article_count']}") + print(f"历史案例数: {demo_summary['historical_case_count']}") + print() + + print("分诊结果") + print("-" * 76) + for item in result.triage_results: + print(f"[{item.priority.upper()}] {item.ticket_id} -> {item.assigned_team}") + print(f" 意图: {item.intent}") + print(f" 动作: {item.recommended_action}") + if item.reply_draft: + print(f" 自动回复: {item.reply_draft}") + print(f" 解释: {'; '.join(item.reason_trace[:3])}") + print() + + print("队列摘要") + print("-" * 76) + for queue in result.queue_summaries: + print( + f"{queue.team_name}: open={queue.open_ticket_count}, " + f"high={queue.high_priority_count}, auto_reply={queue.auto_reply_candidate_count}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="SAGE Customer Support Ticket Triage", + ) + parser.add_argument( + "--storage-path", + type=Path, + default=None, + help="Optional JSON state path for persisting ticket state.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON output instead of a console summary.", + ) + args = parser.parse_args() + + service = TicketTriageApplicationService(storage_path=args.storage_path) + result = service.run_demo(reset=True) + + if args.json: + print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2)) + return + + print_console_report(result, service.get_demo_summary()) + + +if __name__ == "__main__": + main() diff --git a/examples/run_ticket_triage_api.py b/examples/run_ticket_triage_api.py new file mode 100644 index 0000000..a0c8fa9 --- /dev/null +++ b/examples/run_ticket_triage_api.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Run the customer support ticket triage FastAPI service.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +try: + import uvicorn + + from sage.apps.ticket_triage import TicketTriageApplicationService, create_fastapi_app +except ImportError as exc: + print(f"Error importing ticket triage API dependencies: {exc}") + print("\nPlease install sage-apps dependencies in your active environment.") + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run the SAGE Ticket Triage FastAPI service", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python examples/run_ticket_triage_api.py + python examples/run_ticket_triage_api.py --host 0.0.0.0 --port 8020 + python examples/run_ticket_triage_api.py --storage-path .sage/ticket-triage-state.json + """, + ) + parser.add_argument("--host", default="127.0.0.1", help="Host to bind the API server.") + parser.add_argument("--port", type=int, default=8010, help="Port to bind the API server.") + parser.add_argument( + "--storage-path", + type=Path, + default=None, + help="Optional JSON state path for persisting ticket triage state.", + ) + args = parser.parse_args() + + service = TicketTriageApplicationService(storage_path=args.storage_path) + app = create_fastapi_app(service) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/examples/test_apps.py b/examples/test_apps.py index 0b5d689..df4ddd2 100644 --- a/examples/test_apps.py +++ b/examples/test_apps.py @@ -87,9 +87,7 @@ def test_examples_boundary_no_core_layer_imports(): """示例入口不应直接依赖核心实现层。""" apps_dir = Path(__file__).parent retired_prefixes = ("kernel", "platform", "middleware", "libs") - forbidden_prefixes = ( - *(f"sage.{name}" for name in retired_prefixes), - ) + forbidden_prefixes = (*(f"sage.{name}" for name in retired_prefixes),) violations: list[str] = []