first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Agent4 learning and assessment backend."""
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
AGENT4_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKSPACE_ROOT = AGENT4_ROOT.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Agent4 SW/ANSYS Learning Platform"
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 7200
|
||||
|
||||
knowledge_framework_path: Path = AGENT4_ROOT / "data" / "knowledge" / "reducer_three_layer_framework.json"
|
||||
legacy_knowledge_framework_path: Path = WORKSPACE_ROOT / "mechanical_knowledge_base_final" / "data" / "framework" / "reducer_three_layer_framework.json"
|
||||
standard_skillflow_dirs: list[Path] = Field(
|
||||
default_factory=lambda: [
|
||||
AGENT4_ROOT / "data" / "standards",
|
||||
WORKSPACE_ROOT / "runtime_tmp",
|
||||
]
|
||||
)
|
||||
|
||||
part_feature_audit_project: Path = AGENT4_ROOT / "tools" / "modeling-process" / "PartFeatureAudit" / "PartFeatureAudit.csproj"
|
||||
assembly_knowledge_audit_project: Path = AGENT4_ROOT / "tools" / "modeling-process" / "AssemblyKnowledgeAudit" / "AssemblyKnowledgeAudit.csproj"
|
||||
runtime_dir: Path = AGENT4_ROOT / "runtime"
|
||||
|
||||
project_paths_file: Path = AGENT4_ROOT / "project_paths.json"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(AGENT4_ROOT / ".env"),
|
||||
env_prefix="AGENT4_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
def software_paths(self) -> dict[str, Any]:
|
||||
if not self.project_paths_file.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.project_paths_file.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .config import AGENT4_ROOT, settings
|
||||
from .models import SkillStep
|
||||
from .skillflow import normalize_steps
|
||||
|
||||
|
||||
class SolidWorksExtractor:
|
||||
def audit_part(self, part_path: str) -> list[SkillStep]:
|
||||
path = Path(part_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(part_path)
|
||||
|
||||
output_dir = settings.runtime_dir / "audits" / path.stem
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
"dotnet",
|
||||
"run",
|
||||
"--project",
|
||||
str(settings.part_feature_audit_project),
|
||||
"--",
|
||||
str(path),
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
]
|
||||
subprocess.run(command, check=True, cwd=str(AGENT4_ROOT))
|
||||
return self._read_latest_steps(output_dir)
|
||||
|
||||
def audit_active_part(self) -> list[SkillStep]:
|
||||
output_dir = settings.runtime_dir / "audits" / "active-part"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
"dotnet",
|
||||
"run",
|
||||
"--project",
|
||||
str(settings.part_feature_audit_project),
|
||||
"--",
|
||||
"--active",
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
]
|
||||
subprocess.run(command, check=True, cwd=str(AGENT4_ROOT))
|
||||
return self._read_latest_steps(output_dir)
|
||||
|
||||
def _read_latest_steps(self, output_dir: Path) -> list[SkillStep]:
|
||||
candidates = sorted(
|
||||
list(output_dir.glob("*_modeling_plan.json")) + list(output_dir.glob("*_skill_flow.json")),
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"No audit JSON generated under {output_dir}")
|
||||
payload = json.loads(candidates[0].read_text(encoding="utf-8-sig"))
|
||||
return normalize_steps(payload)
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import CompareIssue, GradeReport, LessonStep, SkillStep, StepCompareResult
|
||||
|
||||
|
||||
CORE_ARGUMENTS = {
|
||||
"extrude_boss_mm": ["depth_mm", "end_condition_code", "through_all"],
|
||||
"extrude_cut_mm": ["depth_mm", "end_condition_code", "through_all"],
|
||||
"shell_remove_face_at_point_mm": ["thickness_mm"],
|
||||
"draw_circle_diameter_mm": ["diameter_mm", "center_model_mm", "cx_mm", "cy_mm"],
|
||||
"fillet_edges_radius_mm": ["radius_mm"],
|
||||
"chamfer_edges_angle_distance_mm": ["distance_mm", "angle_deg"],
|
||||
"linear_pattern_feature_mm": ["count1", "spacing1_mm"],
|
||||
"circular_pattern_feature": ["count", "angle_deg"],
|
||||
}
|
||||
|
||||
|
||||
class FeatureGrader:
|
||||
def compare_step(self, expected: LessonStep, observed: SkillStep | None) -> StepCompareResult:
|
||||
if observed is None:
|
||||
return StepCompareResult(
|
||||
ok=False,
|
||||
score=0,
|
||||
expected_step=expected,
|
||||
feedback_text=f"没有检测到本步骤的操作。请执行:{expected.title}。",
|
||||
issues=[
|
||||
CompareIssue(
|
||||
code="missing_operation",
|
||||
message=f"Expected {expected.expected_skill}, but no user feature was observed.",
|
||||
expected={"skill": expected.expected_skill},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
issues: list[CompareIssue] = []
|
||||
if expected.expected_skill != observed.skill:
|
||||
issues.append(
|
||||
CompareIssue(
|
||||
code="wrong_feature_method",
|
||||
message=f"方法错误:标准答案要求 {expected.expected_skill},学生实际使用 {observed.skill}。",
|
||||
expected={"skill": expected.expected_skill, "title": expected.title},
|
||||
actual={"skill": observed.skill, "name": observed.name, "source_feature": observed.source_feature},
|
||||
)
|
||||
)
|
||||
|
||||
issues.extend(compare_core_arguments(expected.expected_skill, expected.expected_arguments, observed.arguments))
|
||||
ok = not issues
|
||||
return StepCompareResult(
|
||||
ok=ok,
|
||||
score=100.0 if ok else max(0.0, 60.0 - 20.0 * len(issues)),
|
||||
expected_step=expected,
|
||||
observed_step=observed,
|
||||
issues=issues,
|
||||
feedback_text=success_feedback(expected) if ok else failure_feedback(expected, issues),
|
||||
)
|
||||
|
||||
def grade_sequence(self, expected: list[LessonStep], observed: list[SkillStep]) -> GradeReport:
|
||||
issues: list[CompareIssue] = []
|
||||
passed = 0
|
||||
total = len(expected)
|
||||
for index, step in enumerate(expected):
|
||||
obs = observed[index] if index < len(observed) else None
|
||||
result = self.compare_step(step, obs)
|
||||
if result.ok:
|
||||
passed += 1
|
||||
issues.extend(result.issues)
|
||||
|
||||
score = 100.0 if total == 0 else round((passed / total) * 100.0, 2)
|
||||
return GradeReport(
|
||||
ok=score >= 90.0 and not any(issue.severity == "error" for issue in issues),
|
||||
score=score,
|
||||
passed_steps=passed,
|
||||
total_steps=total,
|
||||
issues=issues,
|
||||
summary=f"Passed {passed}/{total} feature-method steps.",
|
||||
)
|
||||
|
||||
|
||||
def compare_core_arguments(skill: str, expected: dict[str, object], actual: dict[str, object]) -> list[CompareIssue]:
|
||||
result: list[CompareIssue] = []
|
||||
for key in CORE_ARGUMENTS.get(skill, []):
|
||||
if key not in expected:
|
||||
continue
|
||||
if key not in actual:
|
||||
result.append(
|
||||
CompareIssue(
|
||||
code="missing_parameter",
|
||||
message=f"参数缺失:{key}。",
|
||||
expected={key: expected.get(key)},
|
||||
actual={},
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not values_close(expected.get(key), actual.get(key)):
|
||||
result.append(
|
||||
CompareIssue(
|
||||
code="wrong_parameter",
|
||||
message=f"参数错误:{key} 标准值为 {expected.get(key)},实际值为 {actual.get(key)}。",
|
||||
expected={key: expected.get(key)},
|
||||
actual={key: actual.get(key)},
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def values_close(expected: object, actual: object, tolerance: float = 0.05) -> bool:
|
||||
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
|
||||
return abs(float(expected) - float(actual)) <= tolerance
|
||||
if isinstance(expected, list) and isinstance(actual, list) and len(expected) == len(actual):
|
||||
return all(values_close(e, a, tolerance) for e, a in zip(expected, actual))
|
||||
return str(expected) == str(actual)
|
||||
|
||||
|
||||
def success_feedback(expected: LessonStep) -> str:
|
||||
return f"正确。你使用了本步骤要求的 {expected.title},可以进入下一步。"
|
||||
|
||||
|
||||
def failure_feedback(expected: LessonStep, issues: list[CompareIssue]) -> str:
|
||||
first = issues[0].message if issues else "操作不符合标准答案。"
|
||||
return f"{first} 本步骤要求:{expected.voice_text}"
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import settings
|
||||
from .models import StandardAnswer, StandardKind
|
||||
from .skillflow import make_standard_id, tags_for_text, load_standard_from_file
|
||||
|
||||
|
||||
class KnowledgeRepository:
|
||||
def __init__(self) -> None:
|
||||
self._standards: dict[str, StandardAnswer] = {}
|
||||
self.reload()
|
||||
|
||||
def reload(self) -> None:
|
||||
standards: dict[str, StandardAnswer] = {}
|
||||
for item in self._load_manifest_standards():
|
||||
standards[item.standard_id] = item
|
||||
for item in self._load_framework_refs():
|
||||
standards.setdefault(item.standard_id, item)
|
||||
for item in self._load_local_json_standards():
|
||||
standards.setdefault(item.standard_id, item)
|
||||
self._standards = standards
|
||||
|
||||
def list_standards(self, query: str = "", kind: StandardKind | None = None) -> list[StandardAnswer]:
|
||||
items = list(self._standards.values())
|
||||
if kind and kind != StandardKind.unknown:
|
||||
items = [item for item in items if item.kind == kind]
|
||||
if query.strip():
|
||||
tokens = _tokens(query)
|
||||
scored = [(score_standard(tokens, item), item) for item in items]
|
||||
items = [item for score, item in sorted(scored, key=lambda pair: pair[0], reverse=True) if score > 0]
|
||||
return items
|
||||
|
||||
def require(self, standard_id: str) -> StandardAnswer:
|
||||
if standard_id not in self._standards:
|
||||
raise KeyError(f"standard answer not found: {standard_id}")
|
||||
return self._standards[standard_id]
|
||||
|
||||
def _load_local_json_standards(self) -> list[StandardAnswer]:
|
||||
result: list[StandardAnswer] = []
|
||||
for root in settings.standard_skillflow_dirs:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*.json"):
|
||||
if not _looks_like_skillflow_name(path.name):
|
||||
continue
|
||||
standard = load_standard_from_file(path)
|
||||
if standard:
|
||||
result.append(standard)
|
||||
return result
|
||||
|
||||
def _load_manifest_standards(self) -> list[StandardAnswer]:
|
||||
manifest_path = settings.standard_skillflow_dirs[0] / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
return []
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
return []
|
||||
entries = manifest.get("entries")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
|
||||
result: list[StandardAnswer] = []
|
||||
root = manifest_path.parent
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
standard = self._standard_from_manifest_entry(root, entry)
|
||||
if standard:
|
||||
result.append(standard)
|
||||
return result
|
||||
|
||||
def _standard_from_manifest_entry(self, root: Path, entry: dict[str, Any]) -> StandardAnswer | None:
|
||||
preferred = (
|
||||
entry.get("modeling_plan_path")
|
||||
or entry.get("knowledge_skillflow_path")
|
||||
or entry.get("skill_flow_path")
|
||||
or ""
|
||||
)
|
||||
payload_path = root / str(preferred)
|
||||
loaded = load_standard_from_file(payload_path) if preferred else None
|
||||
if loaded is None:
|
||||
return StandardAnswer(
|
||||
standard_id=str(entry.get("standard_id") or ""),
|
||||
title=str(entry.get("title") or entry.get("source_base_name") or ""),
|
||||
kind=_standard_kind(str(entry.get("kind") or "unknown")),
|
||||
source_path=str(payload_path) if preferred else "",
|
||||
source_type="manifest_ref",
|
||||
tags=tags_for_text(f"{entry.get('title', '')} {entry.get('category', '')}"),
|
||||
summary="Manifest standard answer reference without a readable local payload.",
|
||||
steps=[],
|
||||
raw={"manifest_entry": entry},
|
||||
)
|
||||
|
||||
loaded.standard_id = str(entry.get("standard_id") or loaded.standard_id)
|
||||
loaded.title = str(entry.get("title") or loaded.title)
|
||||
loaded.kind = _standard_kind(str(entry.get("kind") or loaded.kind.value))
|
||||
loaded.source_type = "manifest"
|
||||
loaded.tags = sorted(set(loaded.tags + tags_for_text(f"{loaded.title} {entry.get('category', '')}")))
|
||||
loaded.summary = loaded.summary or f"Manifest standard answer: {loaded.title}"
|
||||
loaded.raw = {
|
||||
"manifest_entry": entry,
|
||||
"payload": loaded.raw,
|
||||
}
|
||||
return loaded
|
||||
|
||||
def _load_framework_refs(self) -> list[StandardAnswer]:
|
||||
path = settings.knowledge_framework_path if settings.knowledge_framework_path.exists() else settings.legacy_knowledge_framework_path
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
framework = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
result: list[StandardAnswer] = []
|
||||
impl = framework.get("function_implementation_library", {}) if isinstance(framework, dict) else {}
|
||||
for section in impl.get("sections", []) if isinstance(impl.get("sections"), list) else []:
|
||||
for flow in section.get("skillflows", []) if isinstance(section, dict) and isinstance(section.get("skillflows"), list) else []:
|
||||
result.append(_standard_ref(flow, "assembly", path))
|
||||
|
||||
for part in framework.get("part_library", []) if isinstance(framework, dict) and isinstance(framework.get("part_library"), list) else []:
|
||||
category = str(part.get("category_name") or part.get("category_code") or "")
|
||||
for flow in part.get("modeling_skillflows", []) if isinstance(part, dict) and isinstance(part.get("modeling_skillflows"), list) else []:
|
||||
result.append(_standard_ref(flow, "part", path, category))
|
||||
return [item for item in result if item.standard_id]
|
||||
|
||||
|
||||
def _standard_ref(flow: dict[str, Any], kind: str, framework_path: Path, category: str = "") -> StandardAnswer:
|
||||
title = str(flow.get("skillflow_name") or flow.get("name") or "")
|
||||
source_path = str(flow.get("path") or "")
|
||||
text = f"{title} {source_path} {category}"
|
||||
return StandardAnswer(
|
||||
standard_id=make_standard_id(f"{kind}-{title or source_path}"),
|
||||
title=title or source_path,
|
||||
kind=_standard_kind(kind),
|
||||
source_path=source_path,
|
||||
source_type="framework_ref",
|
||||
tags=tags_for_text(text),
|
||||
summary=f"Knowledge-base standard answer reference from {framework_path.name}.",
|
||||
steps=[],
|
||||
raw={"framework_path": str(framework_path), "category": category, "ref": flow},
|
||||
)
|
||||
|
||||
|
||||
def score_standard(tokens: list[str], item: StandardAnswer) -> int:
|
||||
text = " ".join(
|
||||
[
|
||||
item.standard_id,
|
||||
item.title,
|
||||
item.summary,
|
||||
item.source_path,
|
||||
" ".join(item.tags),
|
||||
" ".join(step.skill + " " + step.name + " " + step.source_feature for step in item.steps[:80]),
|
||||
]
|
||||
).lower()
|
||||
return sum(text.count(token) for token in tokens)
|
||||
|
||||
|
||||
def _tokens(query: str) -> list[str]:
|
||||
lowered = query.lower()
|
||||
raw = [item for item in lowered.replace("_", " ").replace("-", " ").split() if len(item) >= 2]
|
||||
for index in range(len(lowered) - 1):
|
||||
pair = lowered[index : index + 2]
|
||||
if any("\u4e00" <= ch <= "\u9fff" for ch in pair):
|
||||
raw.append(pair)
|
||||
seen: set[str] = set()
|
||||
return [item for item in raw if item not in seen and not seen.add(item)]
|
||||
|
||||
|
||||
def _looks_like_skillflow_name(name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return "skillflow" in lowered or "skill_flow" in lowered or "modeling_plan" in lowered or "knowledge" in lowered
|
||||
|
||||
|
||||
def _standard_kind(value: str) -> StandardKind:
|
||||
try:
|
||||
return StandardKind(value)
|
||||
except ValueError:
|
||||
return StandardKind.unknown
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import LessonStep, StandardAnswer
|
||||
|
||||
|
||||
SKILL_LABELS = {
|
||||
"create_new_part": "新建零件",
|
||||
"create_front_plane_sketch": "在前视基准面创建草图",
|
||||
"create_top_plane_sketch": "在上视基准面创建草图",
|
||||
"create_right_plane_sketch": "在右视基准面创建草图",
|
||||
"draw_circle_diameter_mm": "绘制圆",
|
||||
"draw_line_mm": "绘制直线",
|
||||
"extrude_boss_mm": "凸台拉伸",
|
||||
"extrude_cut_mm": "切除拉伸",
|
||||
"create_revolve_boss_mm": "旋转凸台",
|
||||
"create_revolve_cut_mm": "旋转切除",
|
||||
"shell_remove_face_at_point_mm": "抽壳",
|
||||
"loft_boss_from_profiles": "放样凸台",
|
||||
"loft_cut_from_profiles": "放样切除",
|
||||
"swept_boss_circular_profile_mm": "扫描凸台",
|
||||
"swept_cut_circular_profile_mm": "扫描切除",
|
||||
"fillet_edges_radius_mm": "圆角",
|
||||
"chamfer_edges_angle_distance_mm": "倒角",
|
||||
"hole_wizard_threaded_mm": "异形孔向导",
|
||||
"linear_pattern_feature_mm": "线性阵列",
|
||||
"circular_pattern_feature": "圆周阵列",
|
||||
"mirror_feature_about_plane": "镜像",
|
||||
}
|
||||
|
||||
|
||||
def build_lesson_steps(standard: StandardAnswer) -> list[LessonStep]:
|
||||
result: list[LessonStep] = []
|
||||
for index, step in enumerate(standard.steps, 1):
|
||||
title = SKILL_LABELS.get(step.skill, step.name or step.skill)
|
||||
result.append(
|
||||
LessonStep(
|
||||
step_id=f"lesson-{index:03d}",
|
||||
order=index,
|
||||
title=title,
|
||||
voice_text=voice_text_for(step.skill, title, step.arguments),
|
||||
expected_skill=step.skill,
|
||||
expected_arguments=step.arguments,
|
||||
source_feature=step.source_feature,
|
||||
check_policy={
|
||||
"match": "skill_and_core_arguments",
|
||||
"method_required": True,
|
||||
"geometry_is_secondary": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def voice_text_for(skill: str, title: str, args: dict[str, object]) -> str:
|
||||
if skill.endswith("_sketch") or "sketch" in skill:
|
||||
return f"请在 SolidWorks 中执行:{title}。注意选择正确的基准面或参考面。"
|
||||
if skill == "extrude_boss_mm":
|
||||
return f"请点击特征中的凸台拉伸,深度设置为 {args.get('depth_mm', '标准答案要求值')} 毫米。"
|
||||
if skill == "extrude_cut_mm":
|
||||
return f"请点击特征中的切除拉伸,深度或终止条件按照标准答案设置。不要用抽壳或删除面替代。"
|
||||
if skill == "shell_remove_face_at_point_mm":
|
||||
return f"请点击特征中的抽壳,壁厚设置为 {args.get('thickness_mm', '标准答案要求值')} 毫米,并选择需要移除的开口面。"
|
||||
if "loft" in skill:
|
||||
return "请使用放样命令,按标准答案依次选择轮廓草图。不要用拉伸或扫描替代。"
|
||||
if "swept" in skill:
|
||||
return "请使用扫描命令,选择正确的轮廓和路径。不要用放样或拉伸替代。"
|
||||
return f"请在 SolidWorks 中完成:{title}。完成后系统会读取特征树并判断方法与参数是否正确。"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .extractor import SolidWorksExtractor
|
||||
from .grader import FeatureGrader
|
||||
from .knowledge import KnowledgeRepository
|
||||
from .models import (
|
||||
AuditPartRequest,
|
||||
ObservationRequest,
|
||||
SessionMode,
|
||||
StandardKind,
|
||||
StartSessionRequest,
|
||||
SubmitTestRequest,
|
||||
)
|
||||
from .sessions import LearningService, SessionStore
|
||||
from .skillflow import normalize_steps
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://localhost:5175",
|
||||
"http://127.0.0.1:5175",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
knowledge = KnowledgeRepository()
|
||||
store = SessionStore()
|
||||
grader = FeatureGrader()
|
||||
learning = LearningService(store, grader)
|
||||
extractor = SolidWorksExtractor()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, object]:
|
||||
return {
|
||||
"ok": True,
|
||||
"service": settings.app_name,
|
||||
"standards": len(knowledge.list_standards()),
|
||||
"software_paths": settings.software_paths(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/knowledge/reload")
|
||||
async def reload_knowledge() -> dict[str, object]:
|
||||
knowledge.reload()
|
||||
return {"ok": True, "standards": len(knowledge.list_standards())}
|
||||
|
||||
|
||||
@app.get("/api/standards")
|
||||
async def list_standards(query: str = "", kind: StandardKind | None = None) -> dict[str, object]:
|
||||
items = knowledge.list_standards(query=query, kind=kind)
|
||||
return {"items": [item.model_dump(mode="json", exclude={"raw"}) for item in items]}
|
||||
|
||||
|
||||
@app.get("/api/standards/{standard_id}")
|
||||
async def get_standard(standard_id: str) -> dict[str, object]:
|
||||
try:
|
||||
return knowledge.require(standard_id).model_dump(mode="json")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/teaching/sessions")
|
||||
async def start_teaching(request: StartSessionRequest) -> dict[str, object]:
|
||||
try:
|
||||
standard = knowledge.require(request.standard_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return store.start(SessionMode.teaching, standard).model_dump(mode="json")
|
||||
|
||||
|
||||
@app.post("/api/testing/sessions")
|
||||
async def start_testing(request: StartSessionRequest) -> dict[str, object]:
|
||||
try:
|
||||
standard = knowledge.require(request.standard_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return store.start(SessionMode.testing, standard).model_dump(mode="json")
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session(session_id: str) -> dict[str, object]:
|
||||
try:
|
||||
return store.require(session_id).model_dump(mode="json")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/teaching/sessions/{session_id}/observations")
|
||||
async def submit_teaching_observation(session_id: str, request: ObservationRequest) -> dict[str, object]:
|
||||
observed = normalize_steps({"steps": request.steps})
|
||||
try:
|
||||
result = learning.submit_teaching_observation(session_id, observed)
|
||||
return result.model_dump(mode="json")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/teaching/sessions/{session_id}/audit-part")
|
||||
async def audit_part_for_teaching(session_id: str, request: AuditPartRequest) -> dict[str, object]:
|
||||
try:
|
||||
observed = extractor.audit_part(request.part_path)
|
||||
result = learning.submit_teaching_observation(session_id, observed[-1:] if observed else [])
|
||||
return {"observed_steps": [item.model_dump(mode="json") for item in observed], "result": result.model_dump(mode="json")}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/teaching/sessions/{session_id}/audit-active-part")
|
||||
async def audit_active_part_for_teaching(session_id: str) -> dict[str, object]:
|
||||
try:
|
||||
session = store.require(session_id)
|
||||
observed = extractor.audit_active_part()
|
||||
current_index = session.current_step_index
|
||||
current_step = observed[current_index : current_index + 1] if current_index < len(observed) else []
|
||||
result = learning.submit_teaching_observation(session_id, current_step)
|
||||
return {
|
||||
"observed_steps": [item.model_dump(mode="json") for item in observed],
|
||||
"submitted_steps": [item.model_dump(mode="json") for item in current_step],
|
||||
"result": result.model_dump(mode="json"),
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/testing/sessions/{session_id}/submit")
|
||||
async def submit_test(session_id: str, request: SubmitTestRequest) -> dict[str, object]:
|
||||
try:
|
||||
observed = extractor.audit_part(request.part_path) if request.part_path else normalize_steps({"steps": request.steps})
|
||||
report = learning.submit_test(session_id, observed)
|
||||
return report.model_dump(mode="json")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SessionMode(str, Enum):
|
||||
teaching = "teaching"
|
||||
testing = "testing"
|
||||
|
||||
|
||||
class StandardKind(str, Enum):
|
||||
part = "part"
|
||||
assembly = "assembly"
|
||||
simulation = "simulation"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class SkillStep(BaseModel):
|
||||
id: str = ""
|
||||
order: int = 0
|
||||
skill: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
source_feature: str = ""
|
||||
note: str = ""
|
||||
|
||||
|
||||
class StandardAnswer(BaseModel):
|
||||
standard_id: str
|
||||
title: str
|
||||
kind: StandardKind = StandardKind.unknown
|
||||
source_path: str = ""
|
||||
source_type: str = "skillflow"
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
summary: str = ""
|
||||
steps: list[SkillStep] = Field(default_factory=list)
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LessonStep(BaseModel):
|
||||
step_id: str
|
||||
order: int
|
||||
title: str
|
||||
voice_text: str
|
||||
expected_skill: str
|
||||
expected_arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
source_feature: str = ""
|
||||
check_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CompareIssue(BaseModel):
|
||||
severity: str = "error"
|
||||
code: str
|
||||
message: str
|
||||
expected: dict[str, Any] = Field(default_factory=dict)
|
||||
actual: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StepCompareResult(BaseModel):
|
||||
ok: bool
|
||||
score: float = 0.0
|
||||
expected_step: LessonStep | None = None
|
||||
observed_step: SkillStep | None = None
|
||||
issues: list[CompareIssue] = Field(default_factory=list)
|
||||
feedback_text: str = ""
|
||||
|
||||
|
||||
class GradeReport(BaseModel):
|
||||
ok: bool
|
||||
score: float
|
||||
max_score: float = 100.0
|
||||
passed_steps: int = 0
|
||||
total_steps: int = 0
|
||||
issues: list[CompareIssue] = Field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
class LearningSession(BaseModel):
|
||||
session_id: str = Field(default_factory=lambda: f"sess-{uuid4().hex[:10]}")
|
||||
mode: SessionMode
|
||||
standard_id: str
|
||||
title: str
|
||||
current_step_index: int = 0
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
lesson_steps: list[LessonStep] = Field(default_factory=list)
|
||||
observed_steps: list[SkillStep] = Field(default_factory=list)
|
||||
reports: list[StepCompareResult] = Field(default_factory=list)
|
||||
final_report: GradeReport | None = None
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class StartSessionRequest(BaseModel):
|
||||
standard_id: str
|
||||
|
||||
|
||||
class ObservationRequest(BaseModel):
|
||||
steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AuditPartRequest(BaseModel):
|
||||
part_path: str
|
||||
|
||||
|
||||
class SubmitTestRequest(BaseModel):
|
||||
steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||
part_path: str = ""
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .grader import FeatureGrader
|
||||
from .lesson import build_lesson_steps
|
||||
from .models import LearningSession, SessionMode, SkillStep, StandardAnswer, StepCompareResult
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[str, LearningSession] = {}
|
||||
|
||||
def start(self, mode: SessionMode, standard: StandardAnswer) -> LearningSession:
|
||||
session = LearningSession(
|
||||
mode=mode,
|
||||
standard_id=standard.standard_id,
|
||||
title=standard.title,
|
||||
lesson_steps=build_lesson_steps(standard),
|
||||
)
|
||||
self._items[session.session_id] = session
|
||||
return session
|
||||
|
||||
def require(self, session_id: str) -> LearningSession:
|
||||
if session_id not in self._items:
|
||||
raise KeyError(f"session not found: {session_id}")
|
||||
return self._items[session_id]
|
||||
|
||||
|
||||
class LearningService:
|
||||
def __init__(self, store: SessionStore, grader: FeatureGrader) -> None:
|
||||
self.store = store
|
||||
self.grader = grader
|
||||
|
||||
def submit_teaching_observation(self, session_id: str, observed: list[SkillStep]) -> StepCompareResult:
|
||||
session = self.store.require(session_id)
|
||||
session.observed_steps.extend(observed)
|
||||
expected = session.lesson_steps[session.current_step_index] if session.current_step_index < len(session.lesson_steps) else None
|
||||
if expected is None:
|
||||
session.status = "completed"
|
||||
result = StepCompareResult(ok=True, score=100, feedback_text="教学步骤已经完成。")
|
||||
session.reports.append(result)
|
||||
return result
|
||||
actual = observed[-1] if observed else None
|
||||
result = self.grader.compare_step(expected, actual)
|
||||
session.reports.append(result)
|
||||
if result.ok:
|
||||
session.current_step_index += 1
|
||||
if session.current_step_index >= len(session.lesson_steps):
|
||||
session.status = "completed"
|
||||
return result
|
||||
|
||||
def submit_test(self, session_id: str, observed: list[SkillStep]):
|
||||
session = self.store.require(session_id)
|
||||
session.observed_steps = observed
|
||||
report = self.grader.grade_sequence(session.lesson_steps, observed)
|
||||
session.final_report = report
|
||||
session.status = "completed"
|
||||
return report
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import SkillStep, StandardAnswer, StandardKind
|
||||
|
||||
|
||||
def load_standard_from_file(path: Path) -> StandardAnswer | None:
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
steps = normalize_steps(raw)
|
||||
if not steps:
|
||||
return None
|
||||
|
||||
schema_version = raw.get("schemaVersion", "") if isinstance(raw, dict) else ""
|
||||
kind = StandardKind.assembly if "assembly" in str(schema_version).lower() else StandardKind.part
|
||||
title = path.stem
|
||||
modeling_plan = raw.get("modelingPlan") if isinstance(raw, dict) and isinstance(raw.get("modelingPlan"), dict) else {}
|
||||
approved_text = raw.get("approvedPlanText") if isinstance(raw, dict) else ""
|
||||
summary = str(modeling_plan.get("summary") or approved_text or "")
|
||||
return StandardAnswer(
|
||||
standard_id=make_standard_id(path.stem),
|
||||
title=title,
|
||||
kind=kind,
|
||||
source_path=str(path),
|
||||
source_type="local_json",
|
||||
tags=tags_for_text(f"{title} {summary}"),
|
||||
summary=summary,
|
||||
steps=steps,
|
||||
raw=raw if isinstance(raw, dict) else {"steps": raw},
|
||||
)
|
||||
|
||||
|
||||
def normalize_steps(payload: Any) -> list[SkillStep]:
|
||||
raw_steps: list[Any] = []
|
||||
if isinstance(payload, list):
|
||||
raw_steps = payload
|
||||
elif isinstance(payload, dict):
|
||||
if isinstance(payload.get("steps"), list):
|
||||
raw_steps = payload["steps"]
|
||||
elif isinstance(payload.get("modelingPlan"), dict) and isinstance(payload["modelingPlan"].get("steps"), list):
|
||||
raw_steps = payload["modelingPlan"]["steps"]
|
||||
elif isinstance(payload.get("modeling_plan"), dict) and isinstance(payload["modeling_plan"].get("steps"), list):
|
||||
raw_steps = payload["modeling_plan"]["steps"]
|
||||
|
||||
result: list[SkillStep] = []
|
||||
for index, item in enumerate(raw_steps, 1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
skill = str(item.get("skill") or item.get("skillName") or item.get("skill_name") or "")
|
||||
if not skill:
|
||||
continue
|
||||
args = item.get("args") or item.get("arguments") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
result.append(
|
||||
SkillStep(
|
||||
id=str(item.get("id") or f"step-{index:03d}"),
|
||||
order=int(item.get("step") or index),
|
||||
skill=skill,
|
||||
name=str(item.get("name") or item.get("source_feature") or skill),
|
||||
description=str(item.get("description") or item.get("note") or ""),
|
||||
arguments=args,
|
||||
source_feature=str(item.get("source_feature") or item.get("sourceFeature") or item.get("name") or ""),
|
||||
note=str(item.get("note") or item.get("description") or ""),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def make_standard_id(text: str) -> str:
|
||||
normalized = []
|
||||
for ch in text.lower():
|
||||
if ch.isalnum() or "\u4e00" <= ch <= "\u9fff":
|
||||
normalized.append(ch)
|
||||
else:
|
||||
normalized.append("-")
|
||||
return "-".join(part for part in "".join(normalized).split("-") if part)[:120] or "standard"
|
||||
|
||||
|
||||
def tags_for_text(text: str) -> list[str]:
|
||||
tags: list[str] = []
|
||||
mapping = {
|
||||
"齿轮": "gear",
|
||||
"榻": "gear",
|
||||
"轴": "shaft",
|
||||
"箱": "housing",
|
||||
"端盖": "cover",
|
||||
"减速器": "reducer",
|
||||
"装配": "assembly",
|
||||
"assembly": "assembly",
|
||||
"shell": "shell",
|
||||
"抽壳": "shell",
|
||||
"loft": "loft",
|
||||
"放样": "loft",
|
||||
}
|
||||
lowered = text.lower()
|
||||
for key, tag in mapping.items():
|
||||
if key.lower() in lowered and tag not in tags:
|
||||
tags.append(tag)
|
||||
return tags
|
||||
Reference in New Issue
Block a user