first commit

This commit is contained in:
2026-07-17 17:45:56 +08:00
commit 04c487823b
5873 changed files with 12227228 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Agent4 learning and assessment backend."""
+50
View File
@@ -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()
+58
View File
@@ -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)
+121
View File
@@ -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}"
+184
View File
@@ -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
+68
View File
@@ -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}。完成后系统会读取特征树并判断方法与参数是否正确。"
+151
View File
@@ -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
+113
View File
@@ -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 = ""
+58
View File
@@ -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
+106
View File
@@ -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