3468 lines
138 KiB
C#
3468 lines
138 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Diagnostics;
|
||
using System.Runtime.InteropServices;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using SolidWorks.Interop.sldworks;
|
||
using SolidWorks.Interop.swconst;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
|
||
builder.Configuration.AddJsonFile(
|
||
Path.Combine(builder.Environment.ContentRootPath, "backend-csharp", "Agent4.Api", "appsettings.Local.json"),
|
||
optional: true,
|
||
reloadOnChange: true);
|
||
builder.WebHost.UseUrls(builder.Configuration["urls"] ?? "http://127.0.0.1:7200");
|
||
|
||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||
{
|
||
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||
options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||
});
|
||
|
||
builder.Services.AddCors(options =>
|
||
{
|
||
options.AddDefaultPolicy(policy =>
|
||
{
|
||
policy
|
||
.WithOrigins(
|
||
"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")
|
||
.AllowAnyHeader()
|
||
.AllowAnyMethod()
|
||
.AllowCredentials();
|
||
});
|
||
});
|
||
|
||
builder.Services.AddSingleton<AppPaths>();
|
||
builder.Services.AddSingleton<KnowledgeRepository>();
|
||
builder.Services.AddSingleton<SessionStore>();
|
||
builder.Services.AddSingleton<LessonBuilder>();
|
||
builder.Services.AddSingleton<TeachingLanguageDesigner>();
|
||
builder.Services.AddSingleton<TeachingIntroductionBuilder>();
|
||
builder.Services.AddSingleton<FeatureGrader>();
|
||
builder.Services.AddSingleton<SolidWorksExtractor>();
|
||
builder.Services.AddSingleton<SolidWorksConnectionService>();
|
||
builder.Services.AddMechanicalDiagnostics();
|
||
builder.Services.AddDwgDraft();
|
||
builder.Services.AddDrawingAssemblyDiagnostics();
|
||
|
||
var app = builder.Build();
|
||
app.UseCors();
|
||
app.MapMechanicalDiagnosticApi();
|
||
app.MapDwgDraftApi();
|
||
app.MapDrawingAssemblyDiagnosticApi();
|
||
|
||
app.MapGet("/health", (KnowledgeRepository knowledge, AppPaths paths) => new
|
||
{
|
||
ok = true,
|
||
service = "Agent4 C# SolidWorks Learning API",
|
||
standards = knowledge.ListStandards().Count,
|
||
backend = "csharp",
|
||
software_paths = paths.ReadProjectPaths(),
|
||
});
|
||
|
||
app.MapGet("/api/diagnostic-flows", (AppPaths paths) => Results.Ok(DiagnosticFlowCatalog.Build(paths)));
|
||
|
||
app.MapPost("/api/knowledge/reload", (KnowledgeRepository knowledge) =>
|
||
{
|
||
knowledge.Reload();
|
||
return Results.Ok(new { ok = true, standards = knowledge.ListStandards().Count });
|
||
});
|
||
|
||
app.MapGet("/api/standards", (KnowledgeRepository knowledge, string? query, string? kind) =>
|
||
{
|
||
var items = knowledge.ListStandards(query ?? "", kind ?? "");
|
||
return Results.Ok(new { items });
|
||
});
|
||
|
||
app.MapGet("/api/standards/{standardId}", (KnowledgeRepository knowledge, string standardId) =>
|
||
{
|
||
return knowledge.TryGet(standardId, out var standard)
|
||
? Results.Ok(standard)
|
||
: Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
|
||
});
|
||
|
||
app.MapGet("/api/teaching/design/{standardId}", (
|
||
KnowledgeRepository knowledge,
|
||
TeachingLanguageDesigner designer,
|
||
string standardId) =>
|
||
{
|
||
if (!knowledge.TryGet(standardId, out var standard))
|
||
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
|
||
|
||
return Results.Ok(designer.Design(standard));
|
||
});
|
||
|
||
app.MapGet("/api/teaching/introduction/{standardId}", (
|
||
KnowledgeRepository knowledge,
|
||
TeachingIntroductionBuilder introductionBuilder,
|
||
string standardId) =>
|
||
{
|
||
if (!knowledge.TryGet(standardId, out var standard))
|
||
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
|
||
|
||
return Results.Ok(introductionBuilder.Build(standard));
|
||
});
|
||
|
||
app.MapPost("/api/teaching/introduction/{standardId}/open-standard-part", (
|
||
KnowledgeRepository knowledge,
|
||
TeachingIntroductionBuilder introductionBuilder,
|
||
SolidWorksConnectionService solidWorks,
|
||
string standardId) =>
|
||
{
|
||
if (!knowledge.TryGet(standardId, out var standard))
|
||
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
|
||
|
||
var introduction = introductionBuilder.Build(standard);
|
||
if (string.IsNullOrWhiteSpace(introduction.StandardPartPath) || !File.Exists(introduction.StandardPartPath))
|
||
return Results.NotFound(new { detail = $"standard part file not found: {introduction.StandardPartPath}" });
|
||
|
||
try
|
||
{
|
||
var result = solidWorks.OpenDocument(introduction.StandardPartPath);
|
||
return Results.Ok(new { ok = result.Ok, message = result.Message, part_path = introduction.StandardPartPath });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"打开标准零件失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapPost("/api/teaching/introduction/{standardId}/highlight/{targetId}", (
|
||
KnowledgeRepository knowledge,
|
||
TeachingIntroductionBuilder introductionBuilder,
|
||
SolidWorksConnectionService solidWorks,
|
||
string standardId,
|
||
string targetId) =>
|
||
{
|
||
if (!knowledge.TryGet(standardId, out var standard))
|
||
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
|
||
|
||
var introduction = introductionBuilder.Build(standard);
|
||
if (string.IsNullOrWhiteSpace(introduction.StandardPartPath) || !File.Exists(introduction.StandardPartPath))
|
||
return Results.NotFound(new { detail = $"standard part file not found: {introduction.StandardPartPath}" });
|
||
|
||
var target = introduction.HighlightTargets.FirstOrDefault(item => string.Equals(item.Id, targetId, StringComparison.OrdinalIgnoreCase));
|
||
if (target == null)
|
||
return Results.NotFound(new { detail = $"highlight target not found: {targetId}" });
|
||
|
||
try
|
||
{
|
||
return Results.Ok(solidWorks.HighlightTarget(introduction.StandardPartPath, target));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"标准零件高亮失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapPost("/api/teaching/sessions", (
|
||
StartSessionRequest request,
|
||
KnowledgeRepository knowledge,
|
||
SessionStore sessions,
|
||
LessonBuilder lessonBuilder,
|
||
SolidWorksConnectionService solidWorks) =>
|
||
{
|
||
if (!knowledge.TryGet(request.StandardId, out var standard))
|
||
return Results.NotFound(new { detail = $"standard answer not found: {request.StandardId}" });
|
||
|
||
try
|
||
{
|
||
solidWorks.EnsureConnected();
|
||
return Results.Ok(sessions.Start("teaching", standard, lessonBuilder));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"SolidWorks 启动或连接失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapPost("/api/testing/sessions", (
|
||
StartSessionRequest request,
|
||
KnowledgeRepository knowledge,
|
||
SessionStore sessions,
|
||
LessonBuilder lessonBuilder,
|
||
SolidWorksConnectionService solidWorks) =>
|
||
{
|
||
if (!knowledge.TryGet(request.StandardId, out var standard))
|
||
return Results.NotFound(new { detail = $"standard answer not found: {request.StandardId}" });
|
||
|
||
try
|
||
{
|
||
solidWorks.EnsureConnected();
|
||
return Results.Ok(sessions.Start("testing", standard, lessonBuilder));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"SolidWorks 启动或连接失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapPost("/api/solidworks/connect", (SolidWorksConnectionService solidWorks) =>
|
||
{
|
||
try
|
||
{
|
||
return Results.Ok(solidWorks.EnsureConnected());
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem($"SolidWorks 启动或连接失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapGet("/api/solidworks/active-state", (SolidWorksConnectionService solidWorks) =>
|
||
{
|
||
return Results.Ok(solidWorks.GetActiveDocumentStatus());
|
||
});
|
||
|
||
app.MapGet("/api/sessions/{sessionId}", (SessionStore sessions, string sessionId) =>
|
||
{
|
||
return sessions.TryGet(sessionId, out var session)
|
||
? Results.Ok(session)
|
||
: Results.NotFound(new { detail = $"session not found: {sessionId}" });
|
||
});
|
||
|
||
app.MapPost("/api/teaching/sessions/{sessionId}/observations", (
|
||
string sessionId,
|
||
ObservationRequest request,
|
||
SessionStore sessions,
|
||
FeatureGrader grader) =>
|
||
{
|
||
if (!sessions.TryGet(sessionId, out var session))
|
||
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
|
||
|
||
var observed = SkillFlow.NormalizeSteps(JsonSerializer.SerializeToElement(new { steps = request.Steps }));
|
||
var result = sessions.SubmitTeachingObservation(session, observed, grader);
|
||
return Results.Ok(result);
|
||
});
|
||
|
||
app.MapPost("/api/teaching/sessions/{sessionId}/audit-active-part", async (
|
||
string sessionId,
|
||
SessionStore sessions,
|
||
SolidWorksExtractor extractor,
|
||
FeatureGrader grader) =>
|
||
{
|
||
if (!sessions.TryGet(sessionId, out var session))
|
||
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
|
||
|
||
try
|
||
{
|
||
var observed = await extractor.AuditActivePartAsync();
|
||
var currentIndex = session.CurrentStepIndex;
|
||
var submitted = currentIndex < observed.Count
|
||
? new List<SkillStep> { observed[currentIndex] }
|
||
: new List<SkillStep>();
|
||
var result = sessions.SubmitTeachingObservation(session, submitted, grader);
|
||
return Results.Ok(new
|
||
{
|
||
observed_steps = observed,
|
||
submitted_steps = submitted,
|
||
result,
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem(ex.Message, statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapPost("/api/teaching/sessions/{sessionId}/poll-active-part", async (
|
||
string sessionId,
|
||
SessionStore sessions,
|
||
SolidWorksExtractor extractor,
|
||
FeatureGrader grader,
|
||
SolidWorksConnectionService solidWorks) =>
|
||
{
|
||
if (!sessions.TryGet(sessionId, out var session))
|
||
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
|
||
|
||
try
|
||
{
|
||
var documentStatus = solidWorks.GetActiveDocumentStatus();
|
||
var documentIssue = sessions.CheckActiveDocumentForCurrentStep(session, documentStatus);
|
||
if (documentIssue != null)
|
||
{
|
||
if (documentIssue.Ok)
|
||
sessions.AcceptCurrentStepFromLiveState(session, documentIssue);
|
||
return Results.Ok(new
|
||
{
|
||
observed_step_count = 0,
|
||
submitted_steps = new List<SkillStep>(),
|
||
result = documentIssue,
|
||
session_status = session.Status,
|
||
current_step_index = session.CurrentStepIndex,
|
||
active_document = documentStatus,
|
||
});
|
||
}
|
||
|
||
var observed = await extractor.AuditActivePartAsync();
|
||
var currentIndex = session.CurrentStepIndex;
|
||
var submitted = currentIndex < observed.Count
|
||
? new List<SkillStep> { observed[currentIndex] }
|
||
: new List<SkillStep>();
|
||
var result = sessions.PollTeachingObservation(session, submitted, grader);
|
||
return Results.Ok(new
|
||
{
|
||
observed_step_count = observed.Count,
|
||
submitted_steps = submitted,
|
||
result,
|
||
session_status = session.Status,
|
||
current_step_index = session.CurrentStepIndex,
|
||
active_document = documentStatus,
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem(ex.Message, statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapPost("/api/teaching/sessions/{sessionId}/audit-part", async (
|
||
string sessionId,
|
||
AuditPartRequest request,
|
||
SessionStore sessions,
|
||
SolidWorksExtractor extractor,
|
||
FeatureGrader grader) =>
|
||
{
|
||
if (!sessions.TryGet(sessionId, out var session))
|
||
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
|
||
|
||
try
|
||
{
|
||
var observed = await extractor.AuditPartAsync(request.PartPath);
|
||
var submitted = observed.Count > 0 ? new List<SkillStep> { observed[^1] } : new List<SkillStep>();
|
||
var result = sessions.SubmitTeachingObservation(session, submitted, grader);
|
||
return Results.Ok(new { observed_steps = observed, result });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem(ex.Message, statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.MapPost("/api/testing/sessions/{sessionId}/submit", async (
|
||
string sessionId,
|
||
SubmitTestRequest request,
|
||
SessionStore sessions,
|
||
SolidWorksExtractor extractor,
|
||
FeatureGrader grader) =>
|
||
{
|
||
if (!sessions.TryGet(sessionId, out var session))
|
||
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
|
||
|
||
try
|
||
{
|
||
var observed = string.IsNullOrWhiteSpace(request.PartPath)
|
||
? SkillFlow.NormalizeSteps(JsonSerializer.SerializeToElement(new { steps = request.Steps }))
|
||
: await extractor.AuditPartAsync(request.PartPath);
|
||
var report = sessions.SubmitTest(session, observed, grader);
|
||
return Results.Ok(report);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Results.Problem(ex.Message, statusCode: 500);
|
||
}
|
||
});
|
||
|
||
app.Run();
|
||
|
||
static class DiagnosticFlowCatalog
|
||
{
|
||
public static object Build(AppPaths paths) => new
|
||
{
|
||
schema = "agent4.diagnostic_flow_catalog.v1",
|
||
boundary_doc = Doc(paths.DiagnosticFlowBoundariesDoc),
|
||
flows = new[]
|
||
{
|
||
new
|
||
{
|
||
id = "model_part",
|
||
label = "三维零件查错",
|
||
scope = "single_sldprt_part_design",
|
||
service = "mechanical-diagnostics",
|
||
frontend_target = "part",
|
||
endpoints = new[]
|
||
{
|
||
"POST /api/mechanical-diagnostics/diagnose/start",
|
||
"GET /api/mechanical-diagnostics/diagnose/status/{jobId}",
|
||
"POST /api/mechanical-diagnostics/interfaces/finalize-existing",
|
||
"POST /api/mechanical-diagnostics/diagnose/finalize-existing",
|
||
"POST /api/mechanical-diagnostics/diagnose/load-existing"
|
||
},
|
||
required_inputs = new[] { "model_path(.SLDPRT)", "part_function_profile" },
|
||
responsibilities = new[]
|
||
{
|
||
"提取单零件 B-rep、特征组和高亮图片",
|
||
"生成局部语义子图",
|
||
"检索零件设计禁忌规则",
|
||
"判断零件自身结构设计问题"
|
||
},
|
||
forbidden = new[]
|
||
{
|
||
"不得生成二维图纸公差结论",
|
||
"不得替代装配体接触/配合分析"
|
||
},
|
||
key_outputs = new[]
|
||
{
|
||
"mechanical_semantic_graph.json",
|
||
"local_subgraph_collection.json",
|
||
"final_ai_diagnostic_report.md"
|
||
},
|
||
docs = new[] { Doc(paths.ModelDiagnosticReadmeDoc), Doc(paths.MechanicalDiagnosticContractDoc), Doc(paths.MechanicalDiagnosticUsageDoc) }
|
||
},
|
||
new
|
||
{
|
||
id = "model_assembly",
|
||
label = "三维装配体查错",
|
||
scope = "sldasm_assembly_function_and_nonstandard_part_checks",
|
||
service = "mechanical-diagnostics",
|
||
frontend_target = "assembly",
|
||
endpoints = new[]
|
||
{
|
||
"POST /api/mechanical-diagnostics/diagnose/start",
|
||
"GET /api/mechanical-diagnostics/diagnose/status/{jobId}",
|
||
"POST /api/mechanical-diagnostics/interfaces/finalize-existing",
|
||
"POST /api/mechanical-diagnostics/diagnose/finalize-existing",
|
||
"POST /api/mechanical-diagnostics/diagnose/load-existing"
|
||
},
|
||
required_inputs = new[] { "model_path(.SLDASM)", "product_description", "purchased_component_hints" },
|
||
responsibilities = new[]
|
||
{
|
||
"提取装配体 B-rep、mate、有限接触面和组件图片证据",
|
||
"导出 physical_interfaces 接触高亮图片",
|
||
"VLM 结合接触高亮图、B-rep、mate 和零件功能生成接触面/接口功能描述",
|
||
"生成零件整体功能、接口功能、连接方式和功能组",
|
||
"按功能组判断装配关系问题",
|
||
"对组内非标零件继承装配上下文后执行零件自身查错"
|
||
},
|
||
forbidden = new[]
|
||
{
|
||
"不得只靠 SolidWorks mate 替代 B-rep 接触分析",
|
||
"不得把接触存在直接等价为二维公差具体代号",
|
||
"不得检查外购件/标准件内部结构,除非显式要求"
|
||
},
|
||
key_outputs = new[]
|
||
{
|
||
"diagnostic_context/component_function_introductions.json",
|
||
"diagnostic_context/interface_anchor_analysis.json",
|
||
"diagnostic_context/interface_function_descriptions.json",
|
||
"diagnostic_context/functional_group_descriptions.json",
|
||
"functional_group_evidence/manifest.json",
|
||
"mechanical_semantic_graph.json",
|
||
"final_ai_diagnostic_report.md"
|
||
},
|
||
docs = new[] { Doc(paths.ModelDiagnosticReadmeDoc), Doc(paths.MechanicalDiagnosticContractDoc), Doc(paths.MechanicalDiagnosticUsageDoc) }
|
||
},
|
||
new
|
||
{
|
||
id = "drawing_part",
|
||
label = "二维零件图查错",
|
||
scope = "single_part_dwg_format_check_currently",
|
||
service = "drawing-part-diagnostics",
|
||
frontend_target = "drawing-part",
|
||
endpoints = new[]
|
||
{
|
||
"POST /api/drawing-part-diagnostics/run-full",
|
||
"GET /api/drawing-part-diagnostics/latest",
|
||
"GET /api/drawing-part-diagnostics/image"
|
||
},
|
||
required_inputs = new[] { "student_dwg_path" },
|
||
responsibilities = new[]
|
||
{
|
||
"抽取学生零件 DWG 结构化图元",
|
||
"按程序化规则检查线型、线宽、中心线、虚线、尺寸线、引出线、剖面线等格式问题",
|
||
"生成格式问题证据图片和报告"
|
||
},
|
||
forbidden = new[]
|
||
{
|
||
"当前格式查错不走 AI",
|
||
"不得调用三维装配体接触语义",
|
||
"不得和二维装配图公差接口混用"
|
||
},
|
||
key_outputs = new[] { "format_issues.json", "final/final_report.md", "analysis_result.json" },
|
||
docs = new[] { Doc(paths.DrawingDiagnosticReadmeDoc), Doc(paths.DiagnosticFlowBoundariesDoc) }
|
||
},
|
||
new
|
||
{
|
||
id = "drawing_assembly",
|
||
label = "二维装配图查错",
|
||
scope = "assembly_dwg_annotation_representation_tolerance_binding",
|
||
service = "drawing-assembly-diagnostics",
|
||
frontend_target = "drawing-assembly",
|
||
endpoints = new[]
|
||
{
|
||
"POST /api/drawing-assembly-diagnostics/run-full",
|
||
"POST /api/drawing-assembly-diagnostics/build-interface-drawing-context",
|
||
"POST /api/drawing-assembly-diagnostics/build-tolerance-context",
|
||
"POST /api/drawing-assembly-diagnostics/assess-tolerances",
|
||
"GET /api/drawing-assembly-diagnostics/latest",
|
||
"GET /api/drawing-assembly-diagnostics/image"
|
||
},
|
||
required_inputs = new[] { "student_dwg_path", "model_path or mechanical_context_dir", "ownership_context_dir when reusing existing ownership" },
|
||
responsibilities = new[]
|
||
{
|
||
"抽取学生装配 DWG",
|
||
"复用标准视图/线条归属流水线产物",
|
||
"识别公差标注作用的二维线条/边界",
|
||
"按线条归属、视图位置和三维 B-rep 接触面绑定二维公差与三维接口",
|
||
"多视图汇总同一接口标注",
|
||
"生成多标、漏标、错标候选",
|
||
"AI 结合固定公差知识和三维接触功能语义做最终判断"
|
||
},
|
||
forbidden = new[]
|
||
{
|
||
"不得跳过二维线条归属和多视图汇总,直接用公差文本匹配三维直径",
|
||
"不得修改三维接触语义,只能作为证据使用",
|
||
"不得让三维装配体流程直接输出二维公差结论"
|
||
},
|
||
key_outputs = new[]
|
||
{
|
||
"supplement/tolerance_assessment_context.json",
|
||
"supplement/roughness_assessment_context.json",
|
||
"supplement/assembly_function_context.json",
|
||
"supplement/supplemental_facts.json",
|
||
"tolerance_assessment/tolerance_diagnostic_result.json",
|
||
"tolerance_assessment/tolerance_diagnostic_report.md",
|
||
"final/final_report.md"
|
||
},
|
||
docs = new[] { Doc(paths.DrawingDiagnosticReadmeDoc), Doc(paths.DrawingAssemblyToleranceFlowDoc), Doc(paths.DiagnosticFlowBoundariesDoc) }
|
||
}
|
||
}
|
||
};
|
||
|
||
static object Doc(string path) => new
|
||
{
|
||
path,
|
||
exists = File.Exists(path),
|
||
title = File.Exists(path)
|
||
? File.ReadLines(path).FirstOrDefault(line => line.StartsWith("#", StringComparison.Ordinal))?.TrimStart('#', ' ')
|
||
: ""
|
||
};
|
||
}
|
||
|
||
sealed class AppPaths
|
||
{
|
||
public string ApiRoot { get; } = AppContext.BaseDirectory;
|
||
public string Agent4Root { get; }
|
||
public string WorkspaceRoot { get; }
|
||
public string StandardsRoot => Path.Combine(Agent4Root, "data", "standards");
|
||
public string KnowledgeRoot => Path.Combine(Agent4Root, "data", "knowledge");
|
||
public string ReducerFrameworkPath => Path.Combine(KnowledgeRoot, "reducer_three_layer_framework.json");
|
||
public string ManifestPath => Path.Combine(StandardsRoot, "manifest.json");
|
||
public string RuntimeDir => Path.Combine(Agent4Root, "runtime");
|
||
public string PartFeatureAuditProject => Path.Combine(Agent4Root, "tools", "modeling-process", "PartFeatureAudit", "PartFeatureAudit.csproj");
|
||
public string SectionBrepExtractorProject => Path.Combine(Agent4Root, "tools", "model-diagnostics", "SectionBrepExtractor", "SectionBrepExtractor.csproj");
|
||
public string BrepFaultProbeProject => Path.Combine(Agent4Root, "tools", "model-diagnostics", "BrepFaultProbe", "BrepFaultProbe.csproj");
|
||
public string DiagnosticFlowBoundariesDoc => Path.Combine(Agent4Root, "tools", "DIAGNOSTIC_FLOW_BOUNDARIES.md");
|
||
public string DrawingDiagnosticReadmeDoc => Path.Combine(Agent4Root, "tools", "drawing-diagnostics", "README.md");
|
||
public string DrawingAssemblyToleranceFlowDoc => Path.Combine(Agent4Root, "tools", "drawing-diagnostics", "ASSEMBLY_TOLERANCE_DIAGNOSTIC_FLOW.md");
|
||
public string ModelDiagnosticReadmeDoc => Path.Combine(Agent4Root, "tools", "model-diagnostics", "README.md");
|
||
public string MechanicalDiagnosticContractDoc => Path.Combine(Agent4Root, "tools", "model-diagnostics", "MECHANICAL_DIAGNOSTIC_CONTRACT.md");
|
||
public string MechanicalDiagnosticUsageDoc => Path.Combine(Agent4Root, "tools", "model-diagnostics", "MECHANICAL_DIAGNOSTIC_USAGE.md");
|
||
public string MechanicalKnowledgeIndexRoot => Path.Combine(Agent4Root, "机械设计禁忌1000例_知识库提取", "diagnostic_index");
|
||
public string ProjectPathsFile => Path.Combine(Agent4Root, "project_paths.json");
|
||
public string ReducerModelDir => @"D:\Desktop\减速器\三维";
|
||
public string SolidWorksExe => ReadProjectPathValue("solidworks_exe");
|
||
|
||
public AppPaths()
|
||
{
|
||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "project_paths.json")))
|
||
dir = dir.Parent;
|
||
|
||
Agent4Root = dir?.FullName ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
|
||
WorkspaceRoot = Directory.GetParent(Agent4Root)?.FullName ?? Agent4Root;
|
||
}
|
||
|
||
public Dictionary<string, object?> ReadProjectPaths()
|
||
{
|
||
if (!File.Exists(ProjectPathsFile))
|
||
return new Dictionary<string, object?>();
|
||
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(ProjectPathsFile));
|
||
return doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => (object?)p.Value.ToString());
|
||
}
|
||
catch
|
||
{
|
||
return new Dictionary<string, object?>();
|
||
}
|
||
}
|
||
|
||
string ReadProjectPathValue(string key)
|
||
{
|
||
if (!File.Exists(ProjectPathsFile))
|
||
return "";
|
||
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(ProjectPathsFile));
|
||
return doc.RootElement.TryGetProperty(key, out var value) ? value.ToString() : "";
|
||
}
|
||
catch
|
||
{
|
||
return "";
|
||
}
|
||
}
|
||
}
|
||
|
||
sealed class SolidWorksConnectionService
|
||
{
|
||
readonly AppPaths _paths;
|
||
readonly object _lock = new();
|
||
|
||
public SolidWorksConnectionService(AppPaths paths)
|
||
{
|
||
_paths = paths;
|
||
}
|
||
|
||
public SolidWorksConnectionResult EnsureConnected()
|
||
{
|
||
lock (_lock)
|
||
{
|
||
var alreadyRunning = TryConnectSta();
|
||
if (alreadyRunning.Ok)
|
||
return alreadyRunning;
|
||
|
||
var exe = _paths.SolidWorksExe;
|
||
if (string.IsNullOrWhiteSpace(exe))
|
||
throw new FileNotFoundException("project_paths.json 中没有配置 solidworks_exe。");
|
||
if (!File.Exists(exe))
|
||
throw new FileNotFoundException($"SolidWorks 启动路径不存在:{exe}");
|
||
|
||
Process.Start(new ProcessStartInfo
|
||
{
|
||
FileName = exe,
|
||
UseShellExecute = true,
|
||
WorkingDirectory = Path.GetDirectoryName(exe) ?? _paths.Agent4Root,
|
||
});
|
||
|
||
var deadline = DateTime.UtcNow.AddSeconds(45);
|
||
Exception? lastError = null;
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
Thread.Sleep(1500);
|
||
var connected = TryConnectSta();
|
||
if (connected.Ok)
|
||
return connected with { Started = true };
|
||
if (!string.IsNullOrWhiteSpace(connected.Message))
|
||
lastError = new InvalidOperationException(connected.Message);
|
||
}
|
||
|
||
throw new TimeoutException("已尝试启动 SolidWorks,但 45 秒内没有连接成功。" + (lastError == null ? "" : $" 最后错误:{lastError.Message}"));
|
||
}
|
||
}
|
||
|
||
public SolidWorksConnectionResult OpenDocument(string path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path))
|
||
throw new ArgumentException("标准零件路径为空。", nameof(path));
|
||
if (!File.Exists(path))
|
||
throw new FileNotFoundException($"标准零件文件不存在:{path}");
|
||
|
||
var connected = EnsureConnected();
|
||
Exception? error = null;
|
||
SolidWorksConnectionResult result = connected;
|
||
var thread = new Thread(() =>
|
||
{
|
||
try
|
||
{
|
||
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
|
||
if (hr < 0)
|
||
Marshal.ThrowExceptionForHR(hr);
|
||
|
||
var appObj = GetActiveObject(ref clsid, IntPtr.Zero);
|
||
var app = appObj as SldWorks;
|
||
if (app == null)
|
||
throw new InvalidOperationException("已连接 SolidWorks,但无法获取 SldWorks 应用对象。");
|
||
|
||
app.Visible = true;
|
||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||
var docType = extension == ".sldasm"
|
||
? (int)swDocumentTypes_e.swDocASSEMBLY
|
||
: extension == ".slddrw"
|
||
? (int)swDocumentTypes_e.swDocDRAWING
|
||
: (int)swDocumentTypes_e.swDocPART;
|
||
var errors = 0;
|
||
var warnings = 0;
|
||
var model = app.OpenDoc6(
|
||
path,
|
||
docType,
|
||
(int)swOpenDocOptions_e.swOpenDocOptions_Silent,
|
||
"",
|
||
ref errors,
|
||
ref warnings);
|
||
if (model == null || errors != 0)
|
||
throw new InvalidOperationException($"SolidWorks 打开文档失败,errors={errors}, warnings={warnings}");
|
||
|
||
result = connected with { Message = $"已打开标准零件:{Path.GetFileName(path)}" };
|
||
Marshal.ReleaseComObject(model);
|
||
Marshal.ReleaseComObject(appObj);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
error = ex;
|
||
}
|
||
});
|
||
thread.SetApartmentState(ApartmentState.STA);
|
||
thread.Start();
|
||
thread.Join();
|
||
|
||
if (error != null)
|
||
throw error;
|
||
return result;
|
||
}
|
||
|
||
public HighlightResult HighlightTarget(string partPath, HighlightTarget target)
|
||
{
|
||
if (target == null)
|
||
throw new ArgumentNullException(nameof(target));
|
||
if (string.IsNullOrWhiteSpace(partPath))
|
||
throw new ArgumentException("标准零件路径为空。", nameof(partPath));
|
||
if (!File.Exists(partPath))
|
||
throw new FileNotFoundException($"标准零件文件不存在:{partPath}");
|
||
|
||
EnsureConnected();
|
||
|
||
HighlightResult result = new(false, "", "", $"没有找到可高亮对象:{target.Label}", 0, target.Description);
|
||
Exception? error = null;
|
||
var thread = new Thread(() =>
|
||
{
|
||
try
|
||
{
|
||
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
|
||
if (hr < 0)
|
||
Marshal.ThrowExceptionForHR(hr);
|
||
|
||
var appObj = GetActiveObject(ref clsid, IntPtr.Zero);
|
||
var app = appObj as SldWorks;
|
||
if (app == null)
|
||
throw new InvalidOperationException("已连接 SolidWorks,但无法获取 SldWorks 应用对象。");
|
||
|
||
app.Visible = true;
|
||
var doc = GetOpenPartDocument(app, partPath);
|
||
if (doc == null)
|
||
throw new InvalidOperationException("SolidWorks 当前没有打开标准零件。");
|
||
if (doc.GetType() != (int)swDocumentTypes_e.swDocPART)
|
||
throw new InvalidOperationException("当前活动文档不是零件,无法做功能面高亮。");
|
||
|
||
doc.ClearSelection2(true);
|
||
var matched = TrySelectMatchingFace(doc, target, out var faceResult);
|
||
if (matched)
|
||
{
|
||
doc.GraphicsRedraw2();
|
||
result = faceResult;
|
||
}
|
||
else if (TrySelectSourceFeature(doc, target.SourceFeature, out var featureName))
|
||
{
|
||
doc.GraphicsRedraw2();
|
||
result = new HighlightResult(
|
||
true,
|
||
"feature",
|
||
featureName,
|
||
$"面级匹配不稳定,已回退高亮来源特征:{featureName}。讲解对象:{target.Label}。",
|
||
0,
|
||
target.Description);
|
||
}
|
||
else
|
||
{
|
||
result = new HighlightResult(
|
||
false,
|
||
"none",
|
||
"",
|
||
$"没有匹配到 {target.Label}。面级候选分数={faceResult.Score:0.###},来源特征={target.SourceFeature}。",
|
||
faceResult.Score,
|
||
target.Description);
|
||
}
|
||
|
||
Marshal.ReleaseComObject(doc);
|
||
Marshal.ReleaseComObject(appObj);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
error = ex;
|
||
}
|
||
});
|
||
thread.SetApartmentState(ApartmentState.STA);
|
||
thread.Start();
|
||
thread.Join();
|
||
|
||
if (error != null)
|
||
throw error;
|
||
return result;
|
||
}
|
||
|
||
static ModelDoc2? GetOpenPartDocument(SldWorks app, string partPath)
|
||
{
|
||
var active = app.IActiveDoc2 ?? InvokeCom(app, "ActiveDoc") as ModelDoc2;
|
||
if (active != null && SamePath(ReadDocumentPath(active), partPath))
|
||
return active;
|
||
|
||
var errors = 0;
|
||
var warnings = 0;
|
||
var model = app.OpenDoc6(
|
||
partPath,
|
||
(int)swDocumentTypes_e.swDocPART,
|
||
(int)swOpenDocOptions_e.swOpenDocOptions_Silent,
|
||
"",
|
||
ref errors,
|
||
ref warnings);
|
||
if (model == null || errors != 0)
|
||
throw new InvalidOperationException($"SolidWorks 打开标准零件失败,errors={errors}, warnings={warnings}");
|
||
|
||
return model;
|
||
}
|
||
|
||
static string ReadDocumentPath(ModelDoc2 doc)
|
||
{
|
||
try
|
||
{
|
||
return doc.GetPathName() ?? "";
|
||
}
|
||
catch
|
||
{
|
||
return Convert.ToString(InvokeCom(doc, "GetPathName")) ?? "";
|
||
}
|
||
}
|
||
|
||
static bool SamePath(string left, string right)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
|
||
return false;
|
||
try
|
||
{
|
||
return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
catch
|
||
{
|
||
return string.Equals(left, right, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
}
|
||
|
||
public SolidWorksDocumentStatus GetActiveDocumentStatus()
|
||
{
|
||
SolidWorksDocumentStatus result = new(false, false, 0, "none", "", "尚未连接 SolidWorks。", false, "", "", 0, "", "", new(), new(), new());
|
||
Exception? error = null;
|
||
var thread = new Thread(() =>
|
||
{
|
||
try
|
||
{
|
||
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
|
||
if (hr < 0)
|
||
Marshal.ThrowExceptionForHR(hr);
|
||
|
||
var appObj = GetActiveObject(ref clsid, IntPtr.Zero);
|
||
var app = appObj as SldWorks;
|
||
var doc = app?.IActiveDoc2 ?? InvokeCom(appObj, "ActiveDoc") as ModelDoc2;
|
||
if (doc == null)
|
||
{
|
||
result = new SolidWorksDocumentStatus(true, false, 0, "none", "", "SolidWorks 已连接,但当前没有打开任何文档。", false, "", "", 0, "", "", new(), new(), new());
|
||
Marshal.ReleaseComObject(appObj);
|
||
return;
|
||
}
|
||
|
||
var typeCode = doc.GetType();
|
||
var title = doc.GetTitle() ?? "";
|
||
var sketchStatus = ReadActiveSketchStatus(doc);
|
||
result = new SolidWorksDocumentStatus(
|
||
true,
|
||
true,
|
||
typeCode,
|
||
DocumentTypeName(typeCode),
|
||
title,
|
||
"已读取当前 SolidWorks 文档。",
|
||
sketchStatus.Active,
|
||
sketchStatus.Name,
|
||
sketchStatus.ReferenceName,
|
||
sketchStatus.ReferenceType,
|
||
sketchStatus.ReferenceComType,
|
||
sketchStatus.ReferenceDebug,
|
||
sketchStatus.SelectedObjects,
|
||
sketchStatus.Circles,
|
||
sketchStatus.Dimensions);
|
||
Marshal.ReleaseComObject(doc);
|
||
Marshal.ReleaseComObject(appObj);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
error = ex;
|
||
}
|
||
});
|
||
thread.SetApartmentState(ApartmentState.STA);
|
||
thread.Start();
|
||
thread.Join();
|
||
|
||
return error == null ? result : new SolidWorksDocumentStatus(false, false, 0, "none", "", error.Message, false, "", "", 0, "", "", new(), new(), new());
|
||
}
|
||
|
||
static ActiveSketchStatus ReadActiveSketchStatus(ModelDoc2 doc)
|
||
{
|
||
var selectedObjects = ReadSelectedObjects(doc);
|
||
try
|
||
{
|
||
var sketchManager = doc.SketchManager;
|
||
var activeSketch = sketchManager?.ActiveSketch;
|
||
if (activeSketch == null)
|
||
return new ActiveSketchStatus(false, "", "", 0, "", "SketchManager.ActiveSketch returned null.", selectedObjects, new(), new());
|
||
|
||
var sketchName = "";
|
||
var sketchFeature = InvokeCom(activeSketch, "GetFeature") as Feature;
|
||
if (sketchFeature != null)
|
||
sketchName = sketchFeature.Name ?? "";
|
||
|
||
var referenceType = 0;
|
||
var reference = activeSketch.GetReferenceEntity(ref referenceType);
|
||
var referenceInfo = DescribeSketchReference(doc, reference, referenceType, selectedObjects);
|
||
var circles = ReadActiveSketchCircles(activeSketch);
|
||
var dimensions = ReadActiveSketchDimensions(activeSketch);
|
||
|
||
return new ActiveSketchStatus(
|
||
true,
|
||
sketchName,
|
||
referenceInfo.Name,
|
||
referenceType,
|
||
referenceInfo.ComType,
|
||
referenceInfo.Debug,
|
||
selectedObjects,
|
||
circles,
|
||
dimensions);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return new ActiveSketchStatus(false, "", "", 0, "", ex.Message, selectedObjects, new(), new());
|
||
}
|
||
}
|
||
|
||
static List<SketchCircleInfo> ReadActiveSketchCircles(Sketch activeSketch)
|
||
{
|
||
var result = new List<SketchCircleInfo>();
|
||
foreach (var segmentObj in ToObjectList(activeSketch.GetSketchSegments()))
|
||
{
|
||
if (segmentObj is not SketchSegment segment)
|
||
continue;
|
||
|
||
var segmentType = segment.GetType();
|
||
if (segmentType != (int)swSketchSegments_e.swSketchARC || IsConstructionGeometry(segment))
|
||
continue;
|
||
|
||
if (segment is not SketchArc arc)
|
||
continue;
|
||
|
||
var centerPoint = arc.GetCenterPoint2() as SketchPoint;
|
||
var startPoint = arc.GetStartPoint2() as SketchPoint;
|
||
var endPoint = arc.GetEndPoint2() as SketchPoint;
|
||
var center = ReadSketchPointMm(centerPoint);
|
||
var start = ReadSketchPointMm(startPoint);
|
||
var end = ReadSketchPointMm(endPoint);
|
||
var radiusMm = arc.GetRadius() * 1000.0;
|
||
if (radiusMm <= 0 && center != null && start != null)
|
||
radiusMm = DistanceMm(center, start);
|
||
if (radiusMm <= 0)
|
||
continue;
|
||
|
||
var isFullCircle = start == null || end == null || DistanceMm(start, end) <= 0.001;
|
||
result.Add(new SketchCircleInfo(
|
||
Math.Round(radiusMm * 2.0, 4),
|
||
center == null ? null : Math.Round(center[0], 4),
|
||
center == null ? null : Math.Round(center[1], 4),
|
||
center == null ? null : Math.Round(center[2], 4),
|
||
isFullCircle,
|
||
segmentObj.GetType().FullName ?? segmentObj.GetType().Name));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static List<SketchDimensionInfo> ReadActiveSketchDimensions(Sketch activeSketch)
|
||
{
|
||
var result = new List<SketchDimensionInfo>();
|
||
foreach (var dimensionObj in ToObjectList(InvokeCom(activeSketch, "GetDimensions")))
|
||
{
|
||
var dimension = dimensionObj as Dimension;
|
||
if (dimension == null && dimensionObj is DisplayDimension displayDimension)
|
||
dimension = displayDimension.GetDimension() as Dimension;
|
||
if (dimension == null)
|
||
dimension = InvokeCom(dimensionObj!, "GetDimension") as Dimension;
|
||
if (dimension == null)
|
||
continue;
|
||
|
||
var valueMm = dimension.SystemValue * 1000.0;
|
||
if (valueMm <= 0)
|
||
continue;
|
||
var name = FirstNonEmpty(
|
||
dimension.GetNameForSelection(),
|
||
dimension.Name);
|
||
result.Add(new SketchDimensionInfo(name, Math.Round(valueMm, 4)));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static bool IsConstructionGeometry(SketchSegment segment)
|
||
{
|
||
try
|
||
{
|
||
return segment.ConstructionGeometry;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
static double[]? ReadSketchPointMm(SketchPoint? point)
|
||
{
|
||
if (point == null)
|
||
return null;
|
||
|
||
var x = point.X * 1000.0;
|
||
var y = point.Y * 1000.0;
|
||
var z = point.Z * 1000.0;
|
||
return new[] { x, y, z };
|
||
}
|
||
|
||
static SketchReferenceInfo DescribeSketchReference(
|
||
object doc,
|
||
object? reference,
|
||
int referenceType,
|
||
List<SelectedObjectInfo> selectedObjects)
|
||
{
|
||
if (reference == null)
|
||
{
|
||
var selectedPlane = selectedObjects.FirstOrDefault(x => x.IsDatumPlane);
|
||
return selectedPlane == null
|
||
? new SketchReferenceInfo("", "null", $"GetReferenceEntity returned null. reference_type={referenceType}.")
|
||
: new SketchReferenceInfo(selectedPlane.Name, "selection", $"GetReferenceEntity returned null. Fallback to selected datum plane. reference_type={referenceType}.");
|
||
}
|
||
|
||
var comType = reference.GetType().FullName ?? reference.GetType().Name;
|
||
var typeName = Convert.ToString(InvokeCom(reference, "GetTypeName2")) ?? "";
|
||
var name = FirstNonEmpty(
|
||
Convert.ToString(InvokeCom(reference, "Name")),
|
||
Convert.ToString(InvokeCom(reference, "Name2")));
|
||
|
||
if (string.Equals(typeName, "RefPlane", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(name))
|
||
return new SketchReferenceInfo(name, comType, $"GetReferenceEntity returned Feature RefPlane. reference_type={referenceType}.");
|
||
|
||
var referenceFeature = InvokeCom(reference, "GetFeature");
|
||
var referenceFeatureType = referenceFeature == null ? "" : Convert.ToString(InvokeCom(referenceFeature, "GetTypeName2")) ?? "";
|
||
var referenceFeatureName = referenceFeature == null ? "" : Convert.ToString(InvokeCom(referenceFeature, "Name")) ?? "";
|
||
if (string.Equals(referenceFeatureType, "RefPlane", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(referenceFeatureName))
|
||
return new SketchReferenceInfo(referenceFeatureName, comType, $"GetReferenceEntity returned RefPlane object; GetFeature resolved datum plane. reference_type={referenceType}.");
|
||
|
||
if (referenceType == 4)
|
||
{
|
||
var featureTreeName = FindMatchingRefPlaneFeatureName(doc, reference);
|
||
if (!string.IsNullOrWhiteSpace(featureTreeName))
|
||
return new SketchReferenceInfo(featureTreeName, comType, $"GetReferenceEntity returned swSelDATUMPLANES; matched RefPlane in feature tree. reference_type={referenceType}.");
|
||
|
||
var selectedPlane = selectedObjects.FirstOrDefault(x => x.IsDatumPlane);
|
||
if (selectedPlane != null)
|
||
return new SketchReferenceInfo(selectedPlane.Name, comType, $"GetReferenceEntity returned swSelDATUMPLANES; fallback to selected datum plane. reference_type={referenceType}.");
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(name))
|
||
return new SketchReferenceInfo(name, comType, $"GetReferenceEntity returned named object. reference_type={referenceType}; type_name={typeName}.");
|
||
|
||
if (!string.IsNullOrWhiteSpace(referenceFeatureName))
|
||
return new SketchReferenceInfo(referenceFeatureName, comType, $"GetReferenceEntity feature fallback. reference_type={referenceType}; feature_type={referenceFeatureType}.");
|
||
|
||
return new SketchReferenceInfo("", comType, $"Could not resolve sketch reference name. reference_type={referenceType}; type_name={typeName}; feature_type={referenceFeatureType}.");
|
||
}
|
||
|
||
static string FindMatchingRefPlaneFeatureName(object doc, object reference)
|
||
{
|
||
var targetId = ComIdentity(reference);
|
||
if (targetId == IntPtr.Zero)
|
||
return "";
|
||
|
||
var feature = InvokeCom(doc, "FirstFeature");
|
||
var guard = 0;
|
||
while (feature != null && guard++ < 5000)
|
||
{
|
||
var typeName = Convert.ToString(InvokeCom(feature, "GetTypeName2")) ?? "";
|
||
if (string.Equals(typeName, "RefPlane", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (ComIdentity(feature) == targetId)
|
||
return Convert.ToString(InvokeCom(feature, "Name")) ?? "";
|
||
|
||
var specific = InvokeCom(feature, "GetSpecificFeature2");
|
||
if (specific != null && ComIdentity(specific) == targetId)
|
||
return Convert.ToString(InvokeCom(feature, "Name")) ?? "";
|
||
}
|
||
|
||
feature = InvokeCom(feature, "GetNextFeature");
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
static List<SelectedObjectInfo> ReadSelectedObjects(object doc)
|
||
{
|
||
var result = new List<SelectedObjectInfo>();
|
||
try
|
||
{
|
||
var selectionManager = InvokeCom(doc, "SelectionManager");
|
||
if (selectionManager == null)
|
||
return result;
|
||
|
||
var count = Convert.ToInt32(InvokeCom(selectionManager, "GetSelectedObjectCount2", -1) ?? 0);
|
||
for (var i = 1; i <= count; i++)
|
||
{
|
||
var selected = InvokeCom(selectionManager, "GetSelectedObject6", i, -1);
|
||
var selectedType = Convert.ToInt32(InvokeCom(selectionManager, "GetSelectedObjectType3", i, -1) ?? 0);
|
||
if (selected == null)
|
||
{
|
||
result.Add(new SelectedObjectInfo(i, selectedType, "", "null", "", false));
|
||
continue;
|
||
}
|
||
|
||
var typeName = Convert.ToString(InvokeCom(selected, "GetTypeName2")) ?? "";
|
||
var name = FirstNonEmpty(
|
||
Convert.ToString(InvokeCom(selected, "Name")),
|
||
Convert.ToString(InvokeCom(selected, "Name2")));
|
||
var feature = InvokeCom(selected, "GetFeature");
|
||
if (string.IsNullOrWhiteSpace(name) && feature != null)
|
||
name = Convert.ToString(InvokeCom(feature, "Name")) ?? "";
|
||
var featureType = feature == null ? "" : Convert.ToString(InvokeCom(feature, "GetTypeName2")) ?? "";
|
||
var isDatumPlane = selectedType == 4
|
||
|| string.Equals(typeName, "RefPlane", StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(featureType, "RefPlane", StringComparison.OrdinalIgnoreCase);
|
||
|
||
result.Add(new SelectedObjectInfo(
|
||
i,
|
||
selectedType,
|
||
name,
|
||
selected.GetType().FullName ?? selected.GetType().Name,
|
||
FirstNonEmpty(typeName, featureType),
|
||
isDatumPlane));
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static bool TrySelectMatchingFace(ModelDoc2 doc, HighlightTarget target, out HighlightResult result)
|
||
{
|
||
result = new HighlightResult(false, "face", "", "没有找到匹配的功能面。", double.MaxValue, target.Description);
|
||
if (doc is not PartDoc part)
|
||
return false;
|
||
|
||
var expectedBox = target.BBoxMm;
|
||
var expectedCenter = target.CenterMm;
|
||
var expectedArea = target.AreaMm2;
|
||
Face2? bestFace = null;
|
||
var bestScore = double.MaxValue;
|
||
var bestIndex = 0;
|
||
var index = 0;
|
||
|
||
try
|
||
{
|
||
foreach (var bodyObj in ToObjectList(part.GetBodies2((int)swBodyType_e.swAllBodies, true)))
|
||
{
|
||
if (bodyObj == null)
|
||
continue;
|
||
|
||
foreach (var faceObj in ToObjectList(InvokeCom(bodyObj, "GetFaces")))
|
||
{
|
||
if (faceObj is not Face2 face)
|
||
continue;
|
||
|
||
index++;
|
||
var score = ScoreFace(face, target, expectedBox, expectedCenter, expectedArea);
|
||
if (score < bestScore)
|
||
{
|
||
bestScore = score;
|
||
bestFace = face;
|
||
bestIndex = index;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
|
||
result = new HighlightResult(false, "face", $"face-{bestIndex}", "找到候选面,但置信度不足。", bestScore, target.Description);
|
||
if (bestFace == null || bestScore > 18.0)
|
||
return false;
|
||
|
||
var selected = false;
|
||
try
|
||
{
|
||
if (bestFace is Entity entity)
|
||
selected = entity.Select4(false, null!);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
if (!selected)
|
||
selected = Convert.ToBoolean(InvokeCom(bestFace, "Select4", false, null!) ?? false);
|
||
if (!selected)
|
||
return false;
|
||
|
||
result = new HighlightResult(
|
||
true,
|
||
"face",
|
||
$"face-{bestIndex}",
|
||
$"已高亮 {target.Label}。{target.Description}",
|
||
bestScore,
|
||
target.Description);
|
||
return true;
|
||
}
|
||
|
||
static double ScoreFace(Face2 face, HighlightTarget target, IReadOnlyList<double> expectedBox, IReadOnlyList<double> expectedCenter, double? expectedArea)
|
||
{
|
||
var score = 0.0;
|
||
var surface = "";
|
||
try
|
||
{
|
||
if (face.GetSurface() is Surface faceSurface)
|
||
surface = SurfaceKind(faceSurface);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(target.RawSurfaceType))
|
||
score += string.Equals(surface, target.RawSurfaceType, StringComparison.OrdinalIgnoreCase) ? 0 : 100;
|
||
|
||
var box = TryGetFaceBoxMm(face);
|
||
if (expectedBox.Count >= 6 && box.Count >= 6)
|
||
{
|
||
for (var i = 0; i < 6; i++)
|
||
score += Math.Min(25.0, Math.Abs(expectedBox[i] - box[i]));
|
||
}
|
||
else if (expectedCenter.Count >= 3 && box.Count >= 6)
|
||
{
|
||
var center = new[] { (box[0] + box[3]) / 2.0, (box[1] + box[4]) / 2.0, (box[2] + box[5]) / 2.0 };
|
||
score += Math.Min(50.0, DistanceMm(expectedCenter.ToArray(), center));
|
||
}
|
||
else
|
||
{
|
||
score += 30.0;
|
||
}
|
||
|
||
if (expectedArea.HasValue)
|
||
{
|
||
try
|
||
{
|
||
var area = face.GetArea() * 1_000_000.0;
|
||
score += Math.Min(15.0, Math.Abs(expectedArea.Value - area) / Math.Max(1.0, expectedArea.Value) * 15.0);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
|
||
return score;
|
||
}
|
||
|
||
static bool TrySelectSourceFeature(ModelDoc2 doc, string featureName, out string selectedName)
|
||
{
|
||
selectedName = "";
|
||
if (string.IsNullOrWhiteSpace(featureName))
|
||
return false;
|
||
|
||
try
|
||
{
|
||
var feature = FindFeatureByName(doc, featureName);
|
||
if (feature == null)
|
||
return false;
|
||
|
||
selectedName = feature.Name ?? featureName;
|
||
return feature.Select2(false, 0);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
static Feature? FindFeatureByName(ModelDoc2 doc, string featureName)
|
||
{
|
||
var featureObj = InvokeCom(doc, "FirstFeature");
|
||
var guard = 0;
|
||
while (featureObj != null && guard++ < 5000)
|
||
{
|
||
if (featureObj is not Feature feature)
|
||
break;
|
||
if (string.Equals(feature.Name, featureName, StringComparison.OrdinalIgnoreCase))
|
||
return feature;
|
||
featureObj = InvokeCom(feature, "GetNextFeature");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
static string SurfaceKind(Surface surface)
|
||
{
|
||
try
|
||
{
|
||
if (surface.IsPlane()) return "plane";
|
||
if (surface.IsCylinder()) return "cylinder";
|
||
if (surface.IsCone()) return "cone";
|
||
if (surface.IsSphere()) return "sphere";
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return "unknown";
|
||
}
|
||
|
||
static List<double> TryGetFaceBoxMm(Face2 face)
|
||
{
|
||
try
|
||
{
|
||
return ToDoubleList(face.GetBox()).Take(6).Select(value => Math.Round(value * 1000.0, 6)).ToList();
|
||
}
|
||
catch
|
||
{
|
||
return new List<double>();
|
||
}
|
||
}
|
||
|
||
static SolidWorksConnectionResult TryConnectSta()
|
||
{
|
||
SolidWorksConnectionResult result = new(false, false, "", "");
|
||
Exception? error = null;
|
||
var thread = new Thread(() =>
|
||
{
|
||
try
|
||
{
|
||
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
|
||
if (hr < 0)
|
||
Marshal.ThrowExceptionForHR(hr);
|
||
|
||
var app = GetActiveObject(ref clsid, IntPtr.Zero);
|
||
var visible = InvokeCom(app, "Visible")?.ToString() ?? "";
|
||
var revision = InvokeCom(app, "RevisionNumber")?.ToString() ?? "";
|
||
result = new SolidWorksConnectionResult(true, false, revision, $"宸茶繛鎺?SolidWorks銆俈isible={visible}");
|
||
Marshal.ReleaseComObject(app);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
error = ex;
|
||
}
|
||
});
|
||
thread.SetApartmentState(ApartmentState.STA);
|
||
thread.Start();
|
||
thread.Join();
|
||
|
||
return result.Ok ? result : new SolidWorksConnectionResult(false, false, "", error?.Message ?? "无法连接 SolidWorks。");
|
||
}
|
||
|
||
static object? InvokeCom(object target, string name)
|
||
{
|
||
try
|
||
{
|
||
return target.GetType().InvokeMember(
|
||
name,
|
||
System.Reflection.BindingFlags.GetProperty,
|
||
null,
|
||
target,
|
||
Array.Empty<object>());
|
||
}
|
||
catch
|
||
{
|
||
try
|
||
{
|
||
return target.GetType().InvokeMember(
|
||
name,
|
||
System.Reflection.BindingFlags.InvokeMethod,
|
||
null,
|
||
target,
|
||
Array.Empty<object>());
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
static object? InvokeCom(object target, string name, params object[] args)
|
||
{
|
||
try
|
||
{
|
||
return target.GetType().InvokeMember(
|
||
name,
|
||
System.Reflection.BindingFlags.InvokeMethod,
|
||
null,
|
||
target,
|
||
args);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
static object? InvokeComByRefInt(object target, string name, ref int value)
|
||
{
|
||
try
|
||
{
|
||
object[] args = { value };
|
||
var result = target.GetType().InvokeMember(
|
||
name,
|
||
System.Reflection.BindingFlags.InvokeMethod,
|
||
null,
|
||
target,
|
||
args);
|
||
if (args.Length > 0 && args[0] is int updated)
|
||
value = updated;
|
||
return result;
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
static string FirstNonEmpty(params string?[] values)
|
||
{
|
||
foreach (var value in values)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(value))
|
||
return value;
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
static IntPtr ComIdentity(object? value)
|
||
{
|
||
if (value == null)
|
||
return IntPtr.Zero;
|
||
|
||
IntPtr unknown = IntPtr.Zero;
|
||
try
|
||
{
|
||
unknown = Marshal.GetIUnknownForObject(value);
|
||
return unknown;
|
||
}
|
||
catch
|
||
{
|
||
return IntPtr.Zero;
|
||
}
|
||
finally
|
||
{
|
||
if (unknown != IntPtr.Zero)
|
||
Marshal.Release(unknown);
|
||
}
|
||
}
|
||
|
||
static List<object?> ToObjectList(object? value)
|
||
{
|
||
if (value == null)
|
||
return new List<object?>();
|
||
if (value is object?[] objects)
|
||
return objects.ToList();
|
||
if (value is Array array)
|
||
{
|
||
var result = new List<object?>();
|
||
foreach (var item in array)
|
||
result.Add(item);
|
||
return result;
|
||
}
|
||
|
||
return new List<object?> { value };
|
||
}
|
||
|
||
static List<double> ToDoubleList(object? value)
|
||
{
|
||
if (value == null)
|
||
return new List<double>();
|
||
if (value is double[] doubles)
|
||
return doubles.ToList();
|
||
if (value is Array array)
|
||
{
|
||
var result = new List<double>();
|
||
foreach (var item in array)
|
||
{
|
||
try
|
||
{
|
||
result.Add(Convert.ToDouble(item));
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
try
|
||
{
|
||
return new List<double> { Convert.ToDouble(value) };
|
||
}
|
||
catch
|
||
{
|
||
return new List<double>();
|
||
}
|
||
}
|
||
|
||
static int ToInt(object? value, int fallback = 0)
|
||
{
|
||
if (value == null)
|
||
return fallback;
|
||
try
|
||
{
|
||
return Convert.ToInt32(value);
|
||
}
|
||
catch
|
||
{
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
static double ToDouble(object? value, double fallback = 0.0)
|
||
{
|
||
if (value == null)
|
||
return fallback;
|
||
try
|
||
{
|
||
return Convert.ToDouble(value);
|
||
}
|
||
catch
|
||
{
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
static bool HasComMember(object target, string name)
|
||
{
|
||
return InvokeCom(target, name) != null;
|
||
}
|
||
|
||
static double DistanceMm(double[] a, double[] b)
|
||
{
|
||
if (a.Length < 3 || b.Length < 3)
|
||
return double.MaxValue;
|
||
var dx = a[0] - b[0];
|
||
var dy = a[1] - b[1];
|
||
var dz = a[2] - b[2];
|
||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||
}
|
||
|
||
static string DocumentTypeName(int typeCode)
|
||
{
|
||
return typeCode switch
|
||
{
|
||
1 => "part",
|
||
2 => "assembly",
|
||
3 => "drawing",
|
||
_ => "unknown",
|
||
};
|
||
}
|
||
|
||
[DllImport("ole32.dll", CharSet = CharSet.Unicode)]
|
||
static extern int CLSIDFromProgID(string progId, out Guid clsid);
|
||
|
||
[DllImport("oleaut32.dll", PreserveSig = false)]
|
||
[return: MarshalAs(UnmanagedType.IUnknown)]
|
||
static extern object GetActiveObject(ref Guid clsid, IntPtr reserved);
|
||
}
|
||
|
||
sealed record SolidWorksConnectionResult(bool Ok, bool Started, string Revision, string Message);
|
||
|
||
sealed record SelectedObjectInfo(
|
||
int Index,
|
||
int SelectionType,
|
||
string Name,
|
||
string ComType,
|
||
string SolidWorksTypeName,
|
||
bool IsDatumPlane);
|
||
|
||
sealed record SketchReferenceInfo(string Name, string ComType, string Debug);
|
||
|
||
sealed record SketchCircleInfo(
|
||
double DiameterMm,
|
||
double? CenterXLocalMm,
|
||
double? CenterYLocalMm,
|
||
double? CenterZLocalMm,
|
||
bool IsFullCircle,
|
||
string ComType);
|
||
|
||
sealed record SketchDimensionInfo(string Name, double ValueMm);
|
||
|
||
sealed record ActiveSketchStatus(
|
||
bool Active,
|
||
string Name,
|
||
string ReferenceName,
|
||
int ReferenceType,
|
||
string ReferenceComType,
|
||
string ReferenceDebug,
|
||
List<SelectedObjectInfo> SelectedObjects,
|
||
List<SketchCircleInfo> Circles,
|
||
List<SketchDimensionInfo> Dimensions);
|
||
|
||
sealed record SolidWorksDocumentStatus(
|
||
bool Connected,
|
||
bool HasDocument,
|
||
int TypeCode,
|
||
string TypeName,
|
||
string Title,
|
||
string Message,
|
||
bool ActiveSketch,
|
||
string ActiveSketchName,
|
||
string ActiveSketchReferenceName,
|
||
int ActiveSketchReferenceType,
|
||
string ActiveSketchReferenceComType,
|
||
string ActiveSketchReferenceDebug,
|
||
List<SelectedObjectInfo> SelectedObjects,
|
||
List<SketchCircleInfo> ActiveSketchCircles,
|
||
List<SketchDimensionInfo> ActiveSketchDimensions);
|
||
|
||
sealed class KnowledgeRepository
|
||
{
|
||
readonly AppPaths _paths;
|
||
readonly object _lock = new();
|
||
Dictionary<string, StandardAnswer> _standards = new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
public KnowledgeRepository(AppPaths paths)
|
||
{
|
||
_paths = paths;
|
||
Reload();
|
||
}
|
||
|
||
public void Reload()
|
||
{
|
||
var standards = new Dictionary<string, StandardAnswer>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var item in LoadManifestStandards())
|
||
standards[item.StandardId] = item;
|
||
foreach (var item in LoadLocalJsonStandards())
|
||
standards.TryAdd(item.StandardId, item);
|
||
|
||
lock (_lock)
|
||
_standards = standards;
|
||
}
|
||
|
||
public List<StandardAnswer> ListStandards(string query = "", string kind = "")
|
||
{
|
||
List<StandardAnswer> items;
|
||
lock (_lock)
|
||
items = _standards.Values.ToList();
|
||
|
||
if (!string.IsNullOrWhiteSpace(kind) && !string.Equals(kind, "unknown", StringComparison.OrdinalIgnoreCase))
|
||
items = items.Where(item => string.Equals(item.Kind, kind, StringComparison.OrdinalIgnoreCase)).ToList();
|
||
|
||
if (!string.IsNullOrWhiteSpace(query))
|
||
{
|
||
var tokens = SearchTokens(query);
|
||
items = items
|
||
.Select(item => new { Score = Score(tokens, item), Item = item })
|
||
.Where(pair => pair.Score > 0)
|
||
.OrderByDescending(pair => pair.Score)
|
||
.Select(pair => pair.Item)
|
||
.ToList();
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
public bool TryGet(string standardId, out StandardAnswer standard)
|
||
{
|
||
lock (_lock)
|
||
return _standards.TryGetValue(standardId, out standard!);
|
||
}
|
||
|
||
IEnumerable<StandardAnswer> LoadManifestStandards()
|
||
{
|
||
if (!File.Exists(_paths.ManifestPath))
|
||
yield break;
|
||
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(_paths.ManifestPath));
|
||
if (!doc.RootElement.TryGetProperty("entries", out var entries) || entries.ValueKind != JsonValueKind.Array)
|
||
yield break;
|
||
|
||
foreach (var entry in entries.EnumerateArray())
|
||
{
|
||
var standard = StandardFromManifestEntry(entry);
|
||
if (standard != null)
|
||
yield return standard;
|
||
}
|
||
}
|
||
|
||
StandardAnswer? StandardFromManifestEntry(JsonElement entry)
|
||
{
|
||
var standardId = ReadString(entry, "standard_id");
|
||
if (string.IsNullOrWhiteSpace(standardId))
|
||
return null;
|
||
|
||
var preferred = FirstNonEmpty(
|
||
ReadString(entry, "modeling_plan_path"),
|
||
ReadString(entry, "knowledge_skillflow_path"),
|
||
ReadString(entry, "skill_flow_path"));
|
||
var path = string.IsNullOrWhiteSpace(preferred) ? "" : Path.Combine(_paths.StandardsRoot, preferred);
|
||
var loaded = !string.IsNullOrWhiteSpace(path) ? SkillFlow.LoadStandardFromFile(path) : null;
|
||
|
||
if (loaded == null)
|
||
{
|
||
return new StandardAnswer
|
||
{
|
||
StandardId = standardId,
|
||
Title = FirstNonEmpty(ReadString(entry, "title"), ReadString(entry, "source_base_name")),
|
||
Kind = FirstNonEmpty(ReadString(entry, "kind"), "unknown"),
|
||
SourcePath = path,
|
||
SourceType = "manifest_ref",
|
||
Summary = "Manifest standard answer reference without a readable local payload.",
|
||
Steps = new List<SkillStep>(),
|
||
Tags = TagsForText(ReadString(entry, "title") + " " + ReadString(entry, "category")),
|
||
};
|
||
}
|
||
|
||
loaded.StandardId = standardId;
|
||
loaded.Title = FirstNonEmpty(ReadString(entry, "title"), loaded.Title);
|
||
loaded.Kind = FirstNonEmpty(ReadString(entry, "kind"), loaded.Kind);
|
||
loaded.SourceType = "manifest";
|
||
loaded.Tags = loaded.Tags.Concat(TagsForText(loaded.Title + " " + ReadString(entry, "category"))).Distinct().ToList();
|
||
return loaded;
|
||
}
|
||
|
||
IEnumerable<StandardAnswer> LoadLocalJsonStandards()
|
||
{
|
||
if (!Directory.Exists(_paths.StandardsRoot))
|
||
yield break;
|
||
|
||
foreach (var path in Directory.EnumerateFiles(_paths.StandardsRoot, "*.json", SearchOption.AllDirectories))
|
||
{
|
||
var name = Path.GetFileName(path).ToLowerInvariant();
|
||
if (!name.Contains("skillflow") && !name.Contains("skill_flow") && !name.Contains("modeling_plan") && !name.Contains("knowledge"))
|
||
continue;
|
||
|
||
var standard = SkillFlow.LoadStandardFromFile(path);
|
||
if (standard != null)
|
||
yield return standard;
|
||
}
|
||
}
|
||
|
||
static int Score(List<string> tokens, StandardAnswer item)
|
||
{
|
||
var text = string.Join(" ", new[]
|
||
{
|
||
item.StandardId,
|
||
item.Title,
|
||
item.Summary,
|
||
item.SourcePath,
|
||
string.Join(" ", item.Tags),
|
||
string.Join(" ", item.Steps.Take(80).Select(step => $"{step.Skill} {step.Name} {step.SourceFeature}")),
|
||
}).ToLowerInvariant();
|
||
|
||
return tokens.Sum(token => CountOccurrences(text, token));
|
||
}
|
||
|
||
static int CountOccurrences(string text, string token)
|
||
{
|
||
var count = 0;
|
||
var index = 0;
|
||
while ((index = text.IndexOf(token, index, StringComparison.OrdinalIgnoreCase)) >= 0)
|
||
{
|
||
count++;
|
||
index += token.Length;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
static List<string> SearchTokens(string query)
|
||
{
|
||
var lowered = query.ToLowerInvariant();
|
||
var raw = lowered.Replace("_", " ").Replace("-", " ")
|
||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||
.Where(item => item.Length >= 2)
|
||
.ToList();
|
||
|
||
for (var i = 0; i < lowered.Length - 1; i++)
|
||
{
|
||
var pair = lowered.Substring(i, 2);
|
||
if (pair.Any(ch => ch >= '\u4e00' && ch <= '\u9fff'))
|
||
raw.Add(pair);
|
||
}
|
||
|
||
return raw.Distinct().ToList();
|
||
}
|
||
|
||
static List<string> TagsForText(string text)
|
||
{
|
||
var tags = new List<string>();
|
||
var mapping = new Dictionary<string, string>
|
||
{
|
||
["齿轮"] = "gear",
|
||
["轴"] = "shaft",
|
||
["箱体"] = "housing",
|
||
["端盖"] = "cover",
|
||
["减速器"] = "reducer",
|
||
["装配"] = "assembly",
|
||
["assembly"] = "assembly",
|
||
["shell"] = "shell",
|
||
["鎶藉3"] = "shell",
|
||
["loft"] = "loft",
|
||
["鏀炬牱"] = "loft",
|
||
};
|
||
foreach (var (key, tag) in mapping)
|
||
if (text.Contains(key, StringComparison.OrdinalIgnoreCase) && !tags.Contains(tag))
|
||
tags.Add(tag);
|
||
return tags;
|
||
}
|
||
|
||
static string ReadString(JsonElement element, string name)
|
||
{
|
||
return element.TryGetProperty(name, out var value) && value.ValueKind != JsonValueKind.Null
|
||
? value.ToString()
|
||
: "";
|
||
}
|
||
|
||
static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
|
||
}
|
||
|
||
static class SkillFlow
|
||
{
|
||
public static StandardAnswer? LoadStandardFromFile(string path)
|
||
{
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||
var steps = NormalizeSteps(doc.RootElement);
|
||
if (steps.Count == 0)
|
||
return null;
|
||
|
||
var root = doc.RootElement;
|
||
var schema = TryGetString(root, "schemaVersion");
|
||
var kind = schema.Contains("assembly", StringComparison.OrdinalIgnoreCase) ? "assembly" : "part";
|
||
var modelingPlan = TryGetObject(root, "modelingPlan");
|
||
var summary = modelingPlan.HasValue ? TryGetString(modelingPlan.Value, "summary") : "";
|
||
if (string.IsNullOrWhiteSpace(summary))
|
||
summary = TryGetString(root, "approvedPlanText");
|
||
|
||
var title = Path.GetFileNameWithoutExtension(path);
|
||
return new StandardAnswer
|
||
{
|
||
StandardId = MakeStandardId(title),
|
||
Title = title,
|
||
Kind = kind,
|
||
SourcePath = path,
|
||
SourceType = "local_json",
|
||
Tags = new List<string>(),
|
||
Summary = summary,
|
||
Steps = steps,
|
||
};
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static List<SkillStep> NormalizeSteps(JsonElement payload)
|
||
{
|
||
JsonElement rawSteps;
|
||
if (payload.ValueKind == JsonValueKind.Array)
|
||
{
|
||
rawSteps = payload;
|
||
}
|
||
else if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("steps", out var steps) && steps.ValueKind == JsonValueKind.Array)
|
||
{
|
||
rawSteps = steps;
|
||
}
|
||
else if (payload.ValueKind == JsonValueKind.Object &&
|
||
payload.TryGetProperty("modelingPlan", out var modelingPlan) &&
|
||
modelingPlan.ValueKind == JsonValueKind.Object &&
|
||
modelingPlan.TryGetProperty("steps", out var modelingSteps) &&
|
||
modelingSteps.ValueKind == JsonValueKind.Array)
|
||
{
|
||
rawSteps = modelingSteps;
|
||
}
|
||
else if (payload.ValueKind == JsonValueKind.Object &&
|
||
payload.TryGetProperty("modeling_plan", out var modelingPlanSnake) &&
|
||
modelingPlanSnake.ValueKind == JsonValueKind.Object &&
|
||
modelingPlanSnake.TryGetProperty("steps", out var modelingStepsSnake) &&
|
||
modelingStepsSnake.ValueKind == JsonValueKind.Array)
|
||
{
|
||
rawSteps = modelingStepsSnake;
|
||
}
|
||
else
|
||
{
|
||
return new List<SkillStep>();
|
||
}
|
||
|
||
var result = new List<SkillStep>();
|
||
var index = 1;
|
||
foreach (var item in rawSteps.EnumerateArray())
|
||
{
|
||
if (item.ValueKind != JsonValueKind.Object)
|
||
continue;
|
||
|
||
var skill = FirstNonEmpty(TryGetString(item, "skill"), TryGetString(item, "skillName"), TryGetString(item, "skill_name"));
|
||
if (string.IsNullOrWhiteSpace(skill))
|
||
continue;
|
||
|
||
var args = TryGetObject(item, "args") ?? TryGetObject(item, "arguments");
|
||
result.Add(new SkillStep
|
||
{
|
||
Id = FirstNonEmpty(TryGetString(item, "id"), $"step-{index:000}"),
|
||
Order = TryGetInt(item, "step") ?? index,
|
||
Skill = skill,
|
||
Name = FirstNonEmpty(TryGetString(item, "name"), TryGetString(item, "source_feature"), skill),
|
||
Description = FirstNonEmpty(TryGetString(item, "description"), TryGetString(item, "note")),
|
||
Arguments = args.HasValue ? JsonElementToDictionary(args.Value) : new Dictionary<string, object?>(),
|
||
SourceFeature = FirstNonEmpty(TryGetString(item, "source_feature"), TryGetString(item, "sourceFeature"), TryGetString(item, "name")),
|
||
Note = FirstNonEmpty(TryGetString(item, "note"), TryGetString(item, "description")),
|
||
});
|
||
index++;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
public static string MakeStandardId(string text)
|
||
{
|
||
var chars = text.ToLowerInvariant().Select(ch =>
|
||
char.IsLetterOrDigit(ch) || ch is >= '\u4e00' and <= '\u9fff' ? ch : '-').ToArray();
|
||
var id = string.Join("-", new string(chars).Split('-', StringSplitOptions.RemoveEmptyEntries));
|
||
return string.IsNullOrWhiteSpace(id) ? "standard" : id[..Math.Min(120, id.Length)];
|
||
}
|
||
|
||
public static Dictionary<string, object?> JsonElementToDictionary(JsonElement element)
|
||
{
|
||
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var prop in element.EnumerateObject())
|
||
result[prop.Name] = JsonElementToObject(prop.Value);
|
||
return result;
|
||
}
|
||
|
||
static object? JsonElementToObject(JsonElement element)
|
||
{
|
||
return element.ValueKind switch
|
||
{
|
||
JsonValueKind.String => element.GetString(),
|
||
JsonValueKind.Number => element.TryGetInt64(out var i) ? i : element.GetDouble(),
|
||
JsonValueKind.True => true,
|
||
JsonValueKind.False => false,
|
||
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
|
||
JsonValueKind.Object => JsonElementToDictionary(element),
|
||
_ => null,
|
||
};
|
||
}
|
||
|
||
static string TryGetString(JsonElement element, string name)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Object &&
|
||
element.TryGetProperty(name, out var value) &&
|
||
value.ValueKind != JsonValueKind.Null
|
||
? value.ToString()
|
||
: "";
|
||
}
|
||
|
||
static int? TryGetInt(JsonElement element, string name)
|
||
{
|
||
if (element.ValueKind != JsonValueKind.Object ||
|
||
!element.TryGetProperty(name, out var value) ||
|
||
value.ValueKind != JsonValueKind.Number)
|
||
return null;
|
||
|
||
return value.TryGetInt32(out var intValue) ? intValue : null;
|
||
}
|
||
|
||
static JsonElement? TryGetObject(JsonElement element, string name)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Object &&
|
||
element.TryGetProperty(name, out var value) &&
|
||
value.ValueKind == JsonValueKind.Object
|
||
? value
|
||
: null;
|
||
}
|
||
|
||
static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
|
||
}
|
||
|
||
sealed class LessonBuilder
|
||
{
|
||
readonly TeachingLanguageDesigner _designer;
|
||
|
||
public LessonBuilder(TeachingLanguageDesigner designer)
|
||
{
|
||
_designer = designer;
|
||
}
|
||
|
||
public List<LessonStep> Build(StandardAnswer standard)
|
||
{
|
||
var designedScript = _designer.Design(standard);
|
||
var designedByOrder = designedScript.Steps.ToDictionary(step => step.Order);
|
||
return standard.Steps.Select((step, index) =>
|
||
{
|
||
designedByOrder.TryGetValue(index + 1, out var designed);
|
||
return new LessonStep
|
||
{
|
||
StepId = $"lesson-{index + 1:000}",
|
||
Order = index + 1,
|
||
Title = designed?.Title ?? TeachingLanguageDesigner.LabelFor(step.Skill, step.Name),
|
||
VoiceText = designed?.VoiceText ?? TeachingLanguageDesigner.FallbackVoiceText(step),
|
||
ExpectedSkill = step.Skill,
|
||
ExpectedArguments = step.Arguments,
|
||
SourceFeature = step.SourceFeature,
|
||
TeachingIntent = designed?.TeachingIntent ?? "",
|
||
CommonMistake = designed?.CommonMistake ?? "",
|
||
CheckPolicy = new Dictionary<string, object?>
|
||
{
|
||
["match"] = "skill_and_core_arguments",
|
||
["method_required"] = true,
|
||
["geometry_is_secondary"] = true,
|
||
},
|
||
};
|
||
}).ToList();
|
||
}
|
||
}
|
||
|
||
sealed class TeachingLanguageDesigner
|
||
{
|
||
public TeachingScript Design(StandardAnswer standard)
|
||
{
|
||
var context = PartContext(standard);
|
||
var steps = standard.Steps.Select((step, index) => DesignStep(standard, step, index + 1, context)).ToList();
|
||
return new TeachingScript
|
||
{
|
||
StandardId = standard.StandardId,
|
||
Title = standard.Title,
|
||
Role = "design-teaching-language-agent",
|
||
Summary = $"面向学生的 SolidWorks 建模语音教学脚本。对象:{standard.Title},步骤数:{steps.Count}。",
|
||
Steps = steps,
|
||
};
|
||
}
|
||
|
||
TeachingScriptStep DesignStep(StandardAnswer standard, SkillStep step, int order, string context)
|
||
{
|
||
var title = LabelFor(step.Skill, step.Name);
|
||
var intent = IntentFor(step, context);
|
||
var command = CommandHintFor(step);
|
||
var mistake = MistakeFor(step);
|
||
var parameter = ParameterPhrase(step);
|
||
var voice = VoiceTextFor(order, title, step, intent, command, parameter, mistake);
|
||
return new TeachingScriptStep
|
||
{
|
||
Order = order,
|
||
Skill = step.Skill,
|
||
SourceFeature = step.SourceFeature,
|
||
Title = title,
|
||
VoiceText = voice,
|
||
TeachingIntent = intent.Trim(),
|
||
SolidWorksCommandHint = command.Trim(),
|
||
CommonMistake = mistake.Trim(),
|
||
KeyParameters = CoreParameters(step),
|
||
};
|
||
}
|
||
|
||
static string VoiceTextFor(int order, string title, SkillStep step, string intent, string command, string parameter, string mistake)
|
||
{
|
||
if (string.Equals(step.Skill, "draw_circle_diameter_mm", StringComparison.OrdinalIgnoreCase) &&
|
||
TryGetNumber(step.Arguments, "diameter_mm", out var diameterMm))
|
||
{
|
||
var center = CircleCenterPhrase(step);
|
||
return $"第 {order} 步,{title}。这一步先别急着随手画,直接画一个直径 {FormatMm(diameterMm)} 毫米的圆{center}。在草图工具栏选择圆,画完立刻用智能尺寸把直径标成 {FormatMm(diameterMm)} 毫米。{mistake}";
|
||
}
|
||
|
||
return $"第 {order} 步,{title}。{intent}{command}{parameter}{mistake}";
|
||
}
|
||
|
||
static string PartContext(StandardAnswer standard)
|
||
{
|
||
if (standard.StandardId == "part-gear-gear-1-high-speed-shaft" || standard.Title.Contains("齿轮1+高速轴", StringComparison.OrdinalIgnoreCase))
|
||
return "这是齿轮1与高速轴一体件,教学重点是先建立轴类回转主体,再建立齿轮轮齿、孔槽和修饰特征,学生要理解每个特征为什么选择这种建模方法。";
|
||
return $"当前零件是 {standard.Title},教学重点是按标准建模流程逐步完成特征树。";
|
||
}
|
||
|
||
static string IntentFor(SkillStep step, string context)
|
||
{
|
||
return step.Skill switch
|
||
{
|
||
"create_new_part" => $"先新建一个零件文件,作为后续所有特征的建模环境。{context}",
|
||
"create_front_plane_sketch" => "在前视基准面进入草图,因为这一步需要建立正视方向的截面或轮廓。",
|
||
"create_top_plane_sketch" => "在上视基准面进入草图,用来建立水平参考方向上的轮廓。",
|
||
"create_right_plane_sketch" => "在右视基准面进入草图,用来建立侧向参考轮廓。",
|
||
"create_face_sketch_by_signature" => "在已有实体面上新建草图,后续特征会依赖这个面的位置和方向。",
|
||
"draw_circle_diameter_mm" => "绘制圆形轮廓,这是轴段、齿轮坯或孔特征的基础几何。",
|
||
"draw_line_mm" => "绘制直线轮廓,用来限定截面边界或辅助构造。",
|
||
"draw_center_line_mm" => "绘制中心线,作为旋转、对称或定位的参考。",
|
||
"draw_arc_center_start_end_mm" => "绘制圆弧,形成齿形、过渡轮廓或局部曲线。",
|
||
"draw_spline_points_mm" => "绘制样条曲线,用来表达非直线的齿形或过渡边界。",
|
||
"exit_sketch" => "退出当前草图,让草图可以被后续特征命令调用。",
|
||
"extrude_boss_mm" => "使用凸台拉伸生成实体材料,这是增加实体体积的步骤。",
|
||
"extrude_cut_mm" => "使用切除拉伸去除材料,这是开孔、开槽或削减实体的步骤。",
|
||
"create_revolve_boss_mm" => "使用旋转凸台生成轴对称实体,这比多次拉伸更符合轴类零件建模思路。",
|
||
"create_revolve_cut_mm" => "使用旋转切除去除轴对称材料,适合环槽或回转凹陷。",
|
||
"create_reference_axis" => "创建基准轴,给圆周阵列、旋转或装配定位提供稳定参考。",
|
||
"create_helix_mm" => "创建螺旋线,作为扫描切除或齿形路径的参考曲线。",
|
||
"swept_cut_profile_path" => "使用扫描切除沿路径去除材料,适合齿形或螺旋路径类特征。",
|
||
"swept_cut_circular_profile_mm" => "使用扫描切除,用圆形截面沿路径去除材料。",
|
||
"swept_boss_circular_profile_mm" => "使用扫描凸台,用圆形截面沿路径增加材料。",
|
||
"circular_pattern_feature" => "使用圆周阵列复制种子特征,形成绕轴重复分布的结构。",
|
||
"linear_pattern_feature_mm" => "使用线性阵列复制种子特征,形成等间距重复结构。",
|
||
"mirror_feature_about_plane" => "使用镜像保证左右或前后对称,避免重复手工建模。",
|
||
"fillet_edges_radius_mm" => "添加圆角,减少尖锐边并符合零件加工和装配要求。",
|
||
"chamfer_edges_angle_distance_mm" => "添加倒角,便于装配导入并去除尖角。",
|
||
_ => "按照标准特征树继续完成本步骤,重点保持建模方法和参数一致。",
|
||
};
|
||
}
|
||
|
||
static string CommandHintFor(SkillStep step)
|
||
{
|
||
return step.Skill switch
|
||
{
|
||
"create_new_part" => "在 SolidWorks 中点击新建,选择零件。",
|
||
"create_front_plane_sketch" => "在特征树中选择前视基准面,然后点击草图。",
|
||
"create_top_plane_sketch" => "在特征树中选择上视基准面,然后点击草图。",
|
||
"create_right_plane_sketch" => "在特征树中选择右视基准面,然后点击草图。",
|
||
"create_face_sketch_by_signature" => "选择模型上对应的实体面,然后点击草图。",
|
||
"draw_circle_diameter_mm" => "在草图工具栏选择圆,并用智能尺寸标注直径。",
|
||
"draw_line_mm" => "在草图工具栏选择直线,并按标准轮廓绘制。",
|
||
"draw_center_line_mm" => "在草图工具栏选择中心线。",
|
||
"draw_arc_center_start_end_mm" => "在草图工具栏选择圆弧命令。",
|
||
"draw_spline_points_mm" => "在草图工具栏选择样条曲线。",
|
||
"exit_sketch" => "点击退出草图。",
|
||
"extrude_boss_mm" => "点击特征选项卡中的凸台/基体拉伸。",
|
||
"extrude_cut_mm" => "点击特征选项卡中的拉伸切除。",
|
||
"create_revolve_boss_mm" => "点击特征选项卡中的旋转凸台/基体。",
|
||
"create_revolve_cut_mm" => "点击特征选项卡中的旋转切除。",
|
||
"create_reference_axis" => "点击参考几何体,选择基准轴。",
|
||
"create_helix_mm" => "点击曲线,选择螺旋线/涡状线。",
|
||
"swept_cut_profile_path" or "swept_cut_circular_profile_mm" => "点击特征选项卡中的扫描切除。",
|
||
"swept_boss_circular_profile_mm" => "点击特征选项卡中的扫描凸台/基体。",
|
||
"circular_pattern_feature" => "点击特征选项卡中的圆周阵列。",
|
||
"linear_pattern_feature_mm" => "点击特征选项卡中的线性阵列。",
|
||
"mirror_feature_about_plane" => "点击特征选项卡中的镜像。",
|
||
"fillet_edges_radius_mm" => "点击特征选项卡中的圆角。",
|
||
"chamfer_edges_angle_distance_mm" => "点击特征选项卡中的倒角。",
|
||
_ => "请在 SolidWorks 中选择与本步骤匹配的特征命令。",
|
||
};
|
||
}
|
||
|
||
static string MistakeFor(SkillStep step)
|
||
{
|
||
return step.Skill switch
|
||
{
|
||
"extrude_boss_mm" => "注意这是增加材料,不能误用切除拉伸。",
|
||
"extrude_cut_mm" => "注意这是去除材料,不能用抽壳、删除面或凸台拉伸替代。",
|
||
"create_revolve_boss_mm" => "注意回转体优先使用旋转凸台,不要用多个拉伸堆出来。",
|
||
"create_revolve_cut_mm" => "注意环槽类结构应使用旋转切除,不要用普通拉伸切除替代。",
|
||
"swept_cut_profile_path" or "swept_cut_circular_profile_mm" => "注意扫描切除必须同时选对轮廓和路径,不能用放样或普通切除替代。",
|
||
"circular_pattern_feature" => "注意阵列轴和数量必须正确,不能手工复制多个特征。",
|
||
"mirror_feature_about_plane" => "注意镜像基准面要选对,否则对称方向会错。",
|
||
"fillet_edges_radius_mm" => "注意圆角半径要和标准一致,不要漏选边线。",
|
||
"chamfer_edges_angle_distance_mm" => "注意倒角距离和角度要和标准一致。",
|
||
_ => "完成后系统会读取特征树,检查你使用的方法是否符合标准答案。",
|
||
};
|
||
}
|
||
|
||
static string ParameterPhrase(SkillStep step)
|
||
{
|
||
var parts = CoreParameters(step);
|
||
return parts.Count == 0 ? "" : "关键参数是:" + string.Join(",", parts.Select(pair => $"{ParameterLabel(pair.Key)} 为 {FormatParameterValue(pair.Key, pair.Value)}")) + "。";
|
||
}
|
||
|
||
static string CircleCenterPhrase(SkillStep step)
|
||
{
|
||
if (TryGetNumber(step.Arguments, "cx_mm", out var cx) && TryGetNumber(step.Arguments, "cy_mm", out var cy))
|
||
return $",圆心放在 X 等于 {FormatMm(cx)} 毫米、Y 等于 {FormatMm(cy)} 毫米的位置";
|
||
if (step.Arguments.TryGetValue("center_model_mm", out var value) && value is List<object?> list && list.Count >= 2 &&
|
||
TryGetNumber(list[0], out var x) && TryGetNumber(list[1], out var y))
|
||
return $",圆心参考坐标 X 等于 {FormatMm(x)} 毫米、Y 等于 {FormatMm(y)} 毫米";
|
||
return "";
|
||
}
|
||
|
||
static string ParameterLabel(string key)
|
||
{
|
||
return key switch
|
||
{
|
||
"diameter_mm" => "直径",
|
||
"depth_mm" => "拉伸深度",
|
||
"height_mm" => "高度",
|
||
"pitch_mm" => "螺距",
|
||
"revolution" => "圈数",
|
||
"radius_mm" => "半径",
|
||
"distance_mm" => "距离",
|
||
"angle_deg" => "角度",
|
||
"count" => "数量",
|
||
"count1" => "数量",
|
||
"spacing1_mm" => "间距",
|
||
"axis_name" => "阵列轴",
|
||
"end_condition" => "终止条件",
|
||
"end_condition_code" => "终止条件代码",
|
||
"through_all" => "完全贯穿",
|
||
_ => key,
|
||
};
|
||
}
|
||
|
||
static string FormatParameterValue(string key, object? value)
|
||
{
|
||
if (TryGetNumber(value, out var number))
|
||
{
|
||
if (key.EndsWith("_mm", StringComparison.OrdinalIgnoreCase))
|
||
return $"{FormatMm(number)} 毫米";
|
||
if (key.EndsWith("_deg", StringComparison.OrdinalIgnoreCase))
|
||
return $"{FormatMm(number)} 度";
|
||
return FormatMm(number);
|
||
}
|
||
|
||
return Convert.ToString(value) ?? "";
|
||
}
|
||
|
||
static string FormatMm(double value)
|
||
{
|
||
return Math.Abs(value - Math.Round(value)) < 0.0005
|
||
? Math.Round(value).ToString("0")
|
||
: value.ToString("0.###");
|
||
}
|
||
|
||
static bool TryGetNumber(Dictionary<string, object?> values, string key, out double number)
|
||
{
|
||
number = 0;
|
||
return values.TryGetValue(key, out var value) && TryGetNumber(value, out number);
|
||
}
|
||
|
||
static bool TryGetNumber(object? value, out double number)
|
||
{
|
||
number = 0;
|
||
try
|
||
{
|
||
switch (value)
|
||
{
|
||
case double d:
|
||
number = d;
|
||
return true;
|
||
case float f:
|
||
number = f;
|
||
return true;
|
||
case int i:
|
||
number = i;
|
||
return true;
|
||
case long l:
|
||
number = l;
|
||
return true;
|
||
case decimal m:
|
||
number = (double)m;
|
||
return true;
|
||
case string s:
|
||
return double.TryParse(s, out number);
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
static Dictionary<string, object?> CoreParameters(SkillStep step)
|
||
{
|
||
var keys = step.Skill switch
|
||
{
|
||
"extrude_boss_mm" or "extrude_cut_mm" => new[] { "depth_mm", "end_condition", "end_condition_code", "through_all" },
|
||
"draw_circle_diameter_mm" => new[] { "diameter_mm" },
|
||
"create_revolve_boss_mm" or "create_revolve_cut_mm" => new[] { "angle_deg" },
|
||
"create_helix_mm" => new[] { "height_mm", "pitch_mm", "revolution" },
|
||
"swept_cut_circular_profile_mm" or "swept_boss_circular_profile_mm" => new[] { "diameter_mm" },
|
||
"circular_pattern_feature" => new[] { "count", "angle_deg", "axis_name" },
|
||
"linear_pattern_feature_mm" => new[] { "count1", "spacing1_mm" },
|
||
"fillet_edges_radius_mm" => new[] { "radius_mm" },
|
||
"chamfer_edges_angle_distance_mm" => new[] { "distance_mm", "angle_deg" },
|
||
_ => Array.Empty<string>(),
|
||
};
|
||
return keys.Where(step.Arguments.ContainsKey).ToDictionary(key => key, key => step.Arguments[key]);
|
||
}
|
||
|
||
public static string LabelFor(string skill, string fallback)
|
||
{
|
||
return skill switch
|
||
{
|
||
"create_new_part" => "新建零件",
|
||
"create_front_plane_sketch" => "前视基准面草图",
|
||
"create_top_plane_sketch" => "上视基准面草图",
|
||
"create_right_plane_sketch" => "右视基准面草图",
|
||
"create_face_sketch_by_signature" => "实体面草图",
|
||
"draw_circle_diameter_mm" => "绘制圆并标注直径",
|
||
"draw_line_mm" => "绘制直线",
|
||
"draw_center_line_mm" => "绘制中心线",
|
||
"draw_arc_center_start_end_mm" => "绘制圆弧",
|
||
"draw_spline_points_mm" => "绘制样条曲线",
|
||
"exit_sketch" => "退出草图",
|
||
"extrude_boss_mm" => "凸台拉伸",
|
||
"extrude_cut_mm" => "切除拉伸",
|
||
"create_revolve_boss_mm" => "旋转凸台",
|
||
"create_revolve_cut_mm" => "旋转切除",
|
||
"create_reference_axis" => "创建基准轴",
|
||
"create_helix_mm" => "创建螺旋线",
|
||
"swept_cut_profile_path" => "扫描切除",
|
||
"swept_cut_circular_profile_mm" => "扫描切除",
|
||
"swept_boss_circular_profile_mm" => "扫描凸台",
|
||
"circular_pattern_feature" => "圆周阵列",
|
||
"linear_pattern_feature_mm" => "线性阵列",
|
||
"mirror_feature_about_plane" => "镜像特征",
|
||
"fillet_edges_radius_mm" => "圆角",
|
||
"chamfer_edges_angle_distance_mm" => "倒角",
|
||
_ => string.IsNullOrWhiteSpace(fallback) ? skill : fallback,
|
||
};
|
||
}
|
||
|
||
public static string FallbackVoiceText(SkillStep step)
|
||
{
|
||
return $"请在 SolidWorks 中完成:{LabelFor(step.Skill, step.Name)}。系统会检查特征树中的方法和关键参数。";
|
||
}
|
||
}
|
||
|
||
sealed class TeachingScript
|
||
{
|
||
public string StandardId { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public string Role { get; set; } = "";
|
||
public string Summary { get; set; } = "";
|
||
public List<TeachingScriptStep> Steps { get; set; } = new();
|
||
}
|
||
|
||
sealed class TeachingScriptStep
|
||
{
|
||
public int Order { get; set; }
|
||
public string Skill { get; set; } = "";
|
||
public string SourceFeature { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public string VoiceText { get; set; } = "";
|
||
public string TeachingIntent { get; set; } = "";
|
||
public string SolidWorksCommandHint { get; set; } = "";
|
||
public string CommonMistake { get; set; } = "";
|
||
public Dictionary<string, object?> KeyParameters { get; set; } = new();
|
||
}
|
||
|
||
sealed class TeachingIntroductionBuilder
|
||
{
|
||
readonly AppPaths _paths;
|
||
|
||
public TeachingIntroductionBuilder(AppPaths paths)
|
||
{
|
||
_paths = paths;
|
||
}
|
||
|
||
public TeachingIntroduction Build(StandardAnswer standard)
|
||
{
|
||
var partName = standard.Title;
|
||
var standardPartPath = ResolveStandardPartPath(partName);
|
||
var displayPlanPath = ResolveDisplayPlanPath(partName, standard.SourcePath);
|
||
var planFacts = ExtractPlanFacts(displayPlanPath);
|
||
var frameworkFacts = ExtractFrameworkFacts(partName);
|
||
var role = DescribeRole(partName, frameworkFacts);
|
||
var features = DescribeFeatures(partName, planFacts);
|
||
var modelingFocus = DescribeModelingFocus(standard, planFacts);
|
||
var sourceNotes = new List<string>();
|
||
if (!string.IsNullOrWhiteSpace(frameworkFacts.CategoryName))
|
||
sourceNotes.Add($"知识库三层框架:零件分类={frameworkFacts.CategoryName}");
|
||
if (frameworkFacts.BomNames.Count > 0)
|
||
sourceNotes.Add($"BOM 关联零件:{string.Join("、", frameworkFacts.BomNames.Take(5))}");
|
||
if (!string.IsNullOrWhiteSpace(displayPlanPath))
|
||
sourceNotes.Add($"导入讲解依据:{Path.GetFileName(displayPlanPath)}");
|
||
if (!string.IsNullOrWhiteSpace(standardPartPath))
|
||
sourceNotes.Add($"展示模型:{Path.GetFileName(standardPartPath)}");
|
||
|
||
var highlightSummary = planFacts.HighlightTargets.Count > 0
|
||
? $"知识库识别出 {planFacts.HighlightTargets.Count} 个可讲解的功能面,我会按顺序边高亮边讲。"
|
||
: "当前知识库还没有稳定的功能面语义,我会先按零件整体讲解。";
|
||
var voice = $"先认识标准零件:{partName}。{highlightSummary}";
|
||
return new TeachingIntroduction
|
||
{
|
||
StandardId = standard.StandardId,
|
||
Title = partName,
|
||
StandardPartPath = standardPartPath,
|
||
VoiceText = voice,
|
||
Overview = $"{partName} 是二级齿轮减速器课程中的关键传动零件,本课不是直接背步骤,而是先理解零件在装配体中的功能,再学习为什么要采用这些建模特征。",
|
||
ReducerPosition = role,
|
||
FunctionalRole = BuildFunctionalRole(partName, frameworkFacts),
|
||
KeyFeatures = features,
|
||
ModelingFocus = modelingFocus,
|
||
SourceNotes = sourceNotes,
|
||
KnowledgeSnippets = BuildKnowledgeSnippets(frameworkFacts, planFacts),
|
||
HighlightTargets = planFacts.HighlightTargets,
|
||
KnowledgeCoverage = BuildKnowledgeCoverage(frameworkFacts, planFacts, standardPartPath),
|
||
};
|
||
}
|
||
|
||
string ResolveStandardPartPath(string partName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(partName) || !Directory.Exists(_paths.ReducerModelDir))
|
||
return "";
|
||
|
||
var exact = Path.Combine(_paths.ReducerModelDir, partName + ".SLDPRT");
|
||
if (File.Exists(exact))
|
||
return exact;
|
||
|
||
return Directory.EnumerateFiles(_paths.ReducerModelDir, "*.sldprt", SearchOption.TopDirectoryOnly)
|
||
.Concat(Directory.EnumerateFiles(_paths.ReducerModelDir, "*.SLDPRT", SearchOption.TopDirectoryOnly))
|
||
.FirstOrDefault(path => Path.GetFileNameWithoutExtension(path).Contains(partName, StringComparison.OrdinalIgnoreCase)) ?? "";
|
||
}
|
||
|
||
string ResolveDisplayPlanPath(string partName, string fallback)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(partName) && Directory.Exists(_paths.ReducerModelDir))
|
||
{
|
||
var exact = Path.Combine(_paths.ReducerModelDir, partName + "_modeling_plan.json");
|
||
if (File.Exists(exact))
|
||
return exact;
|
||
}
|
||
|
||
return fallback;
|
||
}
|
||
|
||
FrameworkFacts ExtractFrameworkFacts(string partName)
|
||
{
|
||
var facts = new FrameworkFacts();
|
||
if (!File.Exists(_paths.ReducerFrameworkPath))
|
||
return facts;
|
||
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(_paths.ReducerFrameworkPath));
|
||
if (doc.RootElement.TryGetProperty("function_implementation_library", out var implementation))
|
||
{
|
||
facts.FunctionModule = ReadString(implementation, "function_module");
|
||
facts.Implementation = ReadString(implementation, "implementation");
|
||
}
|
||
|
||
if (!doc.RootElement.TryGetProperty("part_library", out var partLibrary) || partLibrary.ValueKind != JsonValueKind.Array)
|
||
return facts;
|
||
|
||
foreach (var category in partLibrary.EnumerateArray())
|
||
{
|
||
var categoryText = category.GetRawText();
|
||
if (!categoryText.Contains(partName, StringComparison.OrdinalIgnoreCase) &&
|
||
!categoryText.Contains("高速齿轮轴", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
facts.CategoryName = ReadString(category, "category_name");
|
||
if (category.TryGetProperty("bom_parts", out var bomParts) && bomParts.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var part in bomParts.EnumerateArray())
|
||
{
|
||
var name = ReadString(part, "part_name");
|
||
if (!string.IsNullOrWhiteSpace(name))
|
||
facts.BomNames.Add(name);
|
||
}
|
||
}
|
||
|
||
if (category.TryGetProperty("design_knowledge_candidates", out var candidates) && candidates.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var candidate in candidates.EnumerateArray().Take(8))
|
||
{
|
||
var title = ReadString(candidate, "title");
|
||
if (!string.IsNullOrWhiteSpace(title))
|
||
facts.DesignCandidateTitles.Add(title);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return facts;
|
||
}
|
||
|
||
static PlanFacts ExtractPlanFacts(string sourcePath)
|
||
{
|
||
var facts = new PlanFacts();
|
||
if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath))
|
||
return facts;
|
||
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(sourcePath));
|
||
facts.PartPath = ReadString(doc.RootElement, "partPath");
|
||
if (doc.RootElement.TryGetProperty("sourceAssembly", out var sourceAssembly))
|
||
facts.SourceAssembly = ReadString(sourceAssembly, "name");
|
||
if (!doc.RootElement.TryGetProperty("modelingPlan", out var modelingPlan) ||
|
||
!modelingPlan.TryGetProperty("steps", out var steps) ||
|
||
steps.ValueKind != JsonValueKind.Array)
|
||
return facts;
|
||
|
||
foreach (var step in steps.EnumerateArray())
|
||
{
|
||
var skill = ReadString(step, "skillName");
|
||
if (string.IsNullOrWhiteSpace(skill))
|
||
continue;
|
||
facts.SkillCounts[skill] = facts.SkillCounts.TryGetValue(skill, out var count) ? count + 1 : 1;
|
||
|
||
if (!step.TryGetProperty("arguments", out var args) || args.ValueKind != JsonValueKind.Object)
|
||
continue;
|
||
if (args.TryGetProperty("diameter_mm", out var diameter) && diameter.TryGetDouble(out var diameterValue))
|
||
facts.DiametersMm.Add(Math.Round(diameterValue, 3));
|
||
if (args.TryGetProperty("depth_mm", out var depth) && depth.TryGetDouble(out var depthValue))
|
||
facts.DepthMm.Add(Math.Round(depthValue, 3));
|
||
if (args.TryGetProperty("count", out var patternCount) && patternCount.TryGetInt32(out var countValue))
|
||
facts.PatternCounts.Add(countValue);
|
||
}
|
||
|
||
if (doc.RootElement.TryGetProperty("functionalFaces", out var faces) && faces.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var face in faces.EnumerateArray())
|
||
{
|
||
var role = ReadString(face, "face_role");
|
||
if (string.IsNullOrWhiteSpace(role))
|
||
continue;
|
||
var surfaceType = ReadString(face, "surface_type");
|
||
var sourceFeature = ReadString(face, "solidworks_source_feature");
|
||
var target = new HighlightTarget
|
||
{
|
||
Id = $"face-{facts.HighlightTargets.Count + 1:000}",
|
||
Kind = "functional_face",
|
||
Role = role,
|
||
Label = TranslateFaceRole(role),
|
||
SurfaceType = TranslateSurfaceType(surfaceType),
|
||
RawSurfaceType = surfaceType,
|
||
SourceFeature = sourceFeature,
|
||
HighlightStatus = "knowledge_ready_api_pending",
|
||
Description = DescribeFaceRole(role, surfaceType, sourceFeature),
|
||
RadiusMm = ReadDouble(face, "radius_mm"),
|
||
DiameterMm = ReadDouble(face, "diameter_mm"),
|
||
AreaMm2 = ReadDouble(face, "area_mm2"),
|
||
CenterMm = ReadDoubleArray(face, "center_mm"),
|
||
Axis = ReadDoubleArray(face, "axis"),
|
||
Normal = ReadDoubleArray(face, "normal"),
|
||
BBoxMm = ReadDoubleArray(face, "bbox_mm"),
|
||
};
|
||
facts.HighlightTargets.Add(target);
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return facts;
|
||
}
|
||
|
||
static string TranslateFaceRole(string role)
|
||
{
|
||
return role switch
|
||
{
|
||
"bearing_mount_face" => "轴承安装圆柱面",
|
||
"oil_ring_mount_face" => "挡油环安装圆柱面",
|
||
"oil_ring_axial_stop_face" => "挡油环轴向止挡面",
|
||
"distance_mate_face" => "距离配合基准面",
|
||
"cylindrical_mate_face" => "圆柱配合面",
|
||
"keyway_bottom_contact_face" => "键槽底部接触面",
|
||
"keyway_side_contact_face" => "键槽侧向接触面",
|
||
"keyway_end_arc_face" => "键槽端部圆弧面",
|
||
_ => role,
|
||
};
|
||
}
|
||
|
||
static string TranslateSurfaceType(string surfaceType)
|
||
{
|
||
return surfaceType switch
|
||
{
|
||
"cylinder" => "圆柱面",
|
||
"plane" => "平面",
|
||
"cone" => "圆锥面",
|
||
"sphere" => "球面",
|
||
_ => string.IsNullOrWhiteSpace(surfaceType) ? "未知面类型" : surfaceType,
|
||
};
|
||
}
|
||
|
||
static string DescribeFaceRole(string role, string surfaceType, string sourceFeature)
|
||
{
|
||
var label = TranslateFaceRole(role);
|
||
var surface = TranslateSurfaceType(surfaceType);
|
||
var source = string.IsNullOrWhiteSpace(sourceFeature) ? "" : $",来源特征是 {sourceFeature}";
|
||
return $"{label},面类型为{surface}{source}。";
|
||
}
|
||
|
||
static string DescribeRole(string partName, FrameworkFacts facts)
|
||
{
|
||
if (partName.Contains("齿轮1+高速轴", StringComparison.OrdinalIgnoreCase))
|
||
return "它位于减速器的高速级,是把输入端高速旋转传入齿轮传动链的齿轮轴类零件;在装配中它与轴承、端盖以及后续啮合齿轮共同确定高速级的传动位置。";
|
||
if (!string.IsNullOrWhiteSpace(facts.FunctionModule) || !string.IsNullOrWhiteSpace(facts.Implementation))
|
||
return $"它属于{facts.FunctionModule}中的{facts.Implementation}相关零件。";
|
||
return "它是二级齿轮减速器装配体中的一个非标准零件,位置和形状都服务于整机传动与装配关系。";
|
||
}
|
||
|
||
static string BuildFunctionalRole(string partName, FrameworkFacts facts)
|
||
{
|
||
if (partName.Contains("齿轮1+高速轴", StringComparison.OrdinalIgnoreCase))
|
||
return "这个零件同时承担“轴”和“小齿轮”的作用:轴段用于支承和定位,齿轮段用于参与高速级啮合传动。建模时要把回转轴体、齿轮轮廓、齿槽/螺旋线等结构作为一个连续零件来理解。";
|
||
return "这个零件的功能需要结合减速器装配关系理解:外形不是孤立几何,而是由传动、定位、支承、密封或连接需求共同决定。";
|
||
}
|
||
|
||
static string DescribeFeatures(string partName, PlanFacts facts)
|
||
{
|
||
var parts = new List<string>();
|
||
if (facts.SkillCounts.ContainsKey("extrude_boss_mm"))
|
||
parts.Add("包含凸台拉伸形成的基础实体");
|
||
if (facts.SkillCounts.ContainsKey("create_helix_mm"))
|
||
parts.Add("包含螺旋线作为齿形或扫描路径");
|
||
if (facts.SkillCounts.ContainsKey("swept_cut_profile_path") || facts.SkillCounts.ContainsKey("swept_cut_circular_profile_mm"))
|
||
parts.Add("包含扫描切除形成的齿槽或沟槽");
|
||
if (facts.SkillCounts.ContainsKey("circular_pattern_feature"))
|
||
parts.Add("包含圆周阵列形成的重复齿形结构");
|
||
if (facts.SkillCounts.ContainsKey("fillet_edges_radius_mm"))
|
||
parts.Add("包含圆角用于过渡和加工合理性");
|
||
|
||
var diameterText = facts.DiametersMm.Count > 0
|
||
? $"提取到的典型直径包括 {string.Join("、", facts.DiametersMm.Distinct().Take(5).Select(v => $"{v:0.###}mm"))}"
|
||
: "";
|
||
var featureText = parts.Count == 0 ? "主要由草图、实体特征和修饰特征组合完成" : string.Join(",", parts);
|
||
return string.IsNullOrWhiteSpace(diameterText) ? $"它的基本特征是:{featureText}。" : $"它的基本特征是:{featureText};{diameterText}。";
|
||
}
|
||
|
||
static string DescribeModelingFocus(StandardAnswer standard, PlanFacts facts)
|
||
{
|
||
var stepCount = standard.Steps.Count > 0 ? standard.Steps.Count : facts.SkillCounts.Values.Sum();
|
||
var focus = new List<string> { "先建立基准草图和基础实体" };
|
||
if (facts.SkillCounts.ContainsKey("create_helix_mm"))
|
||
focus.Add("再用螺旋线控制齿形路径");
|
||
if (facts.SkillCounts.ContainsKey("swept_cut_profile_path") || facts.SkillCounts.ContainsKey("swept_cut_circular_profile_mm"))
|
||
focus.Add("用扫描切除表达齿槽加工思想");
|
||
if (facts.SkillCounts.ContainsKey("circular_pattern_feature"))
|
||
focus.Add("最后用圆周阵列保证齿形均布");
|
||
focus.Add("修饰边角并保持特征树顺序清晰");
|
||
return $"本标准答案共有 {stepCount} 个建模步骤。建模重点是:{string.Join(",", focus)}。";
|
||
}
|
||
|
||
static List<string> BuildKnowledgeSnippets(FrameworkFacts frameworkFacts, PlanFacts planFacts)
|
||
{
|
||
var snippets = new List<string>();
|
||
if (!string.IsNullOrWhiteSpace(frameworkFacts.FunctionModule))
|
||
snippets.Add($"功能模块:{frameworkFacts.FunctionModule}");
|
||
if (!string.IsNullOrWhiteSpace(frameworkFacts.Implementation))
|
||
snippets.Add($"实现对象:{frameworkFacts.Implementation}");
|
||
if (!string.IsNullOrWhiteSpace(frameworkFacts.CategoryName))
|
||
snippets.Add($"知识库零件分类:{frameworkFacts.CategoryName}");
|
||
if (!string.IsNullOrWhiteSpace(planFacts.SourceAssembly))
|
||
snippets.Add($"来源装配体:{planFacts.SourceAssembly}");
|
||
if (frameworkFacts.DesignCandidateTitles.Count > 0)
|
||
snippets.Add($"关联设计知识:{string.Join("、", frameworkFacts.DesignCandidateTitles.Take(3))}");
|
||
return snippets;
|
||
}
|
||
|
||
static KnowledgeCoverage BuildKnowledgeCoverage(FrameworkFacts frameworkFacts, PlanFacts planFacts, string standardPartPath)
|
||
{
|
||
var missing = new List<string>();
|
||
if (string.IsNullOrWhiteSpace(standardPartPath))
|
||
missing.Add("没有找到可直接打开的标准零件文件");
|
||
if (string.IsNullOrWhiteSpace(frameworkFacts.CategoryName))
|
||
missing.Add("没有找到该零件在三层框架中的分类");
|
||
if (planFacts.HighlightTargets.Count == 0)
|
||
missing.Add("没有功能面语义,无法做面级讲解/高亮");
|
||
|
||
return new KnowledgeCoverage
|
||
{
|
||
HasStandardPart = !string.IsNullOrWhiteSpace(standardPartPath),
|
||
HasFrameworkCategory = !string.IsNullOrWhiteSpace(frameworkFacts.CategoryName),
|
||
HasFunctionalFaces = planFacts.HighlightTargets.Count > 0,
|
||
FunctionalFaceCount = planFacts.HighlightTargets.Count,
|
||
HighlightSupport = planFacts.HighlightTargets.Count > 0 ? "知识库有功能面语义;SolidWorks 面级高亮还需要按面签名验证。" : "当前只能做零件整体/特征级讲解。",
|
||
MissingItems = missing,
|
||
};
|
||
}
|
||
|
||
static string ReadString(JsonElement element, string name)
|
||
{
|
||
return element.ValueKind == JsonValueKind.Object &&
|
||
element.TryGetProperty(name, out var value) &&
|
||
value.ValueKind != JsonValueKind.Null
|
||
? value.ToString()
|
||
: "";
|
||
}
|
||
|
||
static double? ReadDouble(JsonElement element, string name)
|
||
{
|
||
if (element.ValueKind != JsonValueKind.Object ||
|
||
!element.TryGetProperty(name, out var value) ||
|
||
value.ValueKind != JsonValueKind.Number)
|
||
return null;
|
||
|
||
return value.TryGetDouble(out var number) ? number : null;
|
||
}
|
||
|
||
static List<double> ReadDoubleArray(JsonElement element, string name)
|
||
{
|
||
if (element.ValueKind != JsonValueKind.Object ||
|
||
!element.TryGetProperty(name, out var value) ||
|
||
value.ValueKind != JsonValueKind.Array)
|
||
return new List<double>();
|
||
|
||
var result = new List<double>();
|
||
foreach (var item in value.EnumerateArray())
|
||
{
|
||
if (item.ValueKind == JsonValueKind.Number && item.TryGetDouble(out var number))
|
||
result.Add(number);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
sealed class FrameworkFacts
|
||
{
|
||
public string FunctionModule { get; set; } = "";
|
||
public string Implementation { get; set; } = "";
|
||
public string CategoryName { get; set; } = "";
|
||
public List<string> BomNames { get; } = new();
|
||
public List<string> DesignCandidateTitles { get; } = new();
|
||
}
|
||
|
||
sealed class PlanFacts
|
||
{
|
||
public string PartPath { get; set; } = "";
|
||
public string SourceAssembly { get; set; } = "";
|
||
public Dictionary<string, int> SkillCounts { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||
public List<double> DiametersMm { get; } = new();
|
||
public List<double> DepthMm { get; } = new();
|
||
public List<int> PatternCounts { get; } = new();
|
||
public List<HighlightTarget> HighlightTargets { get; } = new();
|
||
}
|
||
}
|
||
|
||
sealed class TeachingIntroduction
|
||
{
|
||
public string StandardId { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public string StandardPartPath { get; set; } = "";
|
||
public string VoiceText { get; set; } = "";
|
||
public string Overview { get; set; } = "";
|
||
public string ReducerPosition { get; set; } = "";
|
||
public string FunctionalRole { get; set; } = "";
|
||
public string KeyFeatures { get; set; } = "";
|
||
public string ModelingFocus { get; set; } = "";
|
||
public List<string> SourceNotes { get; set; } = new();
|
||
public List<string> KnowledgeSnippets { get; set; } = new();
|
||
public List<HighlightTarget> HighlightTargets { get; set; } = new();
|
||
public KnowledgeCoverage KnowledgeCoverage { get; set; } = new();
|
||
}
|
||
|
||
sealed class HighlightTarget
|
||
{
|
||
public string Id { get; set; } = "";
|
||
public string Kind { get; set; } = "";
|
||
public string Role { get; set; } = "";
|
||
public string Label { get; set; } = "";
|
||
public string SurfaceType { get; set; } = "";
|
||
public string RawSurfaceType { get; set; } = "";
|
||
public string SourceFeature { get; set; } = "";
|
||
public string Description { get; set; } = "";
|
||
public string HighlightStatus { get; set; } = "";
|
||
public double? RadiusMm { get; set; }
|
||
public double? DiameterMm { get; set; }
|
||
public double? AreaMm2 { get; set; }
|
||
public List<double> CenterMm { get; set; } = new();
|
||
public List<double> Axis { get; set; } = new();
|
||
public List<double> Normal { get; set; } = new();
|
||
public List<double> BBoxMm { get; set; } = new();
|
||
}
|
||
|
||
sealed class KnowledgeCoverage
|
||
{
|
||
public bool HasStandardPart { get; set; }
|
||
public bool HasFrameworkCategory { get; set; }
|
||
public bool HasFunctionalFaces { get; set; }
|
||
public int FunctionalFaceCount { get; set; }
|
||
public string HighlightSupport { get; set; } = "";
|
||
public List<string> MissingItems { get; set; } = new();
|
||
}
|
||
|
||
sealed record HighlightResult(
|
||
bool Ok,
|
||
string Level,
|
||
string MatchedName,
|
||
string Message,
|
||
double Score,
|
||
string VoiceText);
|
||
|
||
sealed class FeatureGrader
|
||
{
|
||
readonly Dictionary<string, string[]> _coreArguments = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["extrude_boss_mm"] = new[] { "depth_mm", "end_condition_code", "through_all" },
|
||
["extrude_cut_mm"] = new[] { "depth_mm", "end_condition_code", "through_all" },
|
||
["shell_remove_face_at_point_mm"] = new[] { "thickness_mm" },
|
||
["draw_circle_diameter_mm"] = new[] { "diameter_mm", "center_model_mm", "cx_mm", "cy_mm" },
|
||
["fillet_edges_radius_mm"] = new[] { "radius_mm" },
|
||
["chamfer_edges_angle_distance_mm"] = new[] { "distance_mm", "angle_deg" },
|
||
["linear_pattern_feature_mm"] = new[] { "count1", "spacing1_mm" },
|
||
["circular_pattern_feature"] = new[] { "count", "angle_deg" },
|
||
};
|
||
|
||
public StepCompareResult CompareStep(LessonStep expected, SkillStep? observed)
|
||
{
|
||
if (observed == null)
|
||
{
|
||
return new StepCompareResult
|
||
{
|
||
Ok = false,
|
||
Score = 0,
|
||
ExpectedStep = expected,
|
||
FeedbackText = $"没有检测到本步骤的操作。请执行:{expected.Title}。",
|
||
Issues = new List<CompareIssue>
|
||
{
|
||
new()
|
||
{
|
||
Code = "missing_operation",
|
||
Message = $"Expected {expected.ExpectedSkill}, but no user feature was observed.",
|
||
Expected = new Dictionary<string, object?> { ["skill"] = expected.ExpectedSkill },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
var issues = new List<CompareIssue>();
|
||
if (!string.Equals(expected.ExpectedSkill, observed.Skill, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
issues.Add(new CompareIssue
|
||
{
|
||
Code = "wrong_feature_method",
|
||
Message = $"方法错误:标准答案要求 {expected.ExpectedSkill},学生实际使用 {observed.Skill}。",
|
||
Expected = new Dictionary<string, object?> { ["skill"] = expected.ExpectedSkill, ["title"] = expected.Title },
|
||
Actual = new Dictionary<string, object?> { ["skill"] = observed.Skill, ["name"] = observed.Name, ["source_feature"] = observed.SourceFeature },
|
||
});
|
||
}
|
||
|
||
issues.AddRange(CompareCoreArguments(expected.ExpectedSkill, expected.ExpectedArguments, observed.Arguments));
|
||
var ok = issues.Count == 0;
|
||
return new StepCompareResult
|
||
{
|
||
Ok = ok,
|
||
Score = ok ? 100.0 : Math.Max(0.0, 60.0 - 20.0 * issues.Count),
|
||
ExpectedStep = expected,
|
||
ObservedStep = observed,
|
||
Issues = issues,
|
||
FeedbackText = ok
|
||
? $"正确。你使用了本步骤要求的 {expected.Title},可以进入下一步。"
|
||
: $"{issues[0].Message} 本步骤要求:{expected.VoiceText}",
|
||
};
|
||
}
|
||
|
||
public GradeReport GradeSequence(List<LessonStep> expected, List<SkillStep> observed)
|
||
{
|
||
var issues = new List<CompareIssue>();
|
||
var passed = 0;
|
||
for (var i = 0; i < expected.Count; i++)
|
||
{
|
||
var result = CompareStep(expected[i], i < observed.Count ? observed[i] : null);
|
||
if (result.Ok)
|
||
passed++;
|
||
issues.AddRange(result.Issues);
|
||
}
|
||
|
||
var score = expected.Count == 0 ? 100.0 : Math.Round((double)passed / expected.Count * 100.0, 2);
|
||
return new GradeReport
|
||
{
|
||
Ok = score >= 90.0 && issues.All(issue => issue.Severity != "error"),
|
||
Score = score,
|
||
PassedSteps = passed,
|
||
TotalSteps = expected.Count,
|
||
Issues = issues,
|
||
Summary = $"Passed {passed}/{expected.Count} feature-method steps.",
|
||
};
|
||
}
|
||
|
||
IEnumerable<CompareIssue> CompareCoreArguments(string skill, Dictionary<string, object?> expected, Dictionary<string, object?> actual)
|
||
{
|
||
if (!_coreArguments.TryGetValue(skill, out var keys))
|
||
yield break;
|
||
|
||
foreach (var key in keys)
|
||
{
|
||
if (!expected.ContainsKey(key))
|
||
continue;
|
||
if (!actual.ContainsKey(key))
|
||
{
|
||
yield return new CompareIssue
|
||
{
|
||
Code = "missing_parameter",
|
||
Message = $"参数缺失:{key}。",
|
||
Expected = new Dictionary<string, object?> { [key] = expected[key] },
|
||
};
|
||
continue;
|
||
}
|
||
|
||
if (!ValuesClose(expected[key], actual[key]))
|
||
{
|
||
yield return new CompareIssue
|
||
{
|
||
Code = "wrong_parameter",
|
||
Message = $"参数错误:{key} 标准值为 {expected[key]},实际值为 {actual[key]}。",
|
||
Expected = new Dictionary<string, object?> { [key] = expected[key] },
|
||
Actual = new Dictionary<string, object?> { [key] = actual[key] },
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
static bool ValuesClose(object? expected, object? actual, double tolerance = 0.05)
|
||
{
|
||
if (TryDouble(expected, out var expectedDouble) && TryDouble(actual, out var actualDouble))
|
||
return Math.Abs(expectedDouble - actualDouble) <= tolerance;
|
||
|
||
if (expected is List<object?> expectedList && actual is List<object?> actualList && expectedList.Count == actualList.Count)
|
||
return expectedList.Zip(actualList).All(pair => ValuesClose(pair.First, pair.Second, tolerance));
|
||
|
||
return Convert.ToString(expected) == Convert.ToString(actual);
|
||
}
|
||
|
||
static bool TryDouble(object? value, out double result)
|
||
{
|
||
result = 0;
|
||
return value switch
|
||
{
|
||
double d => Set(out result, d),
|
||
float f => Set(out result, f),
|
||
int i => Set(out result, i),
|
||
long l => Set(out result, l),
|
||
decimal m => Set(out result, (double)m),
|
||
string s => double.TryParse(s, out result),
|
||
_ => false,
|
||
};
|
||
}
|
||
|
||
static bool Set(out double target, double value)
|
||
{
|
||
target = value;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
sealed class SessionStore
|
||
{
|
||
readonly ConcurrentDictionary<string, LearningSession> _items = new();
|
||
|
||
public LearningSession Start(string mode, StandardAnswer standard, LessonBuilder lessonBuilder)
|
||
{
|
||
var session = new LearningSession
|
||
{
|
||
SessionId = "sess-" + Guid.NewGuid().ToString("N")[..10],
|
||
Mode = mode,
|
||
StandardId = standard.StandardId,
|
||
Title = standard.Title,
|
||
CreatedAt = DateTime.Now,
|
||
LessonSteps = lessonBuilder.Build(standard),
|
||
};
|
||
_items[session.SessionId] = session;
|
||
return session;
|
||
}
|
||
|
||
public bool TryGet(string sessionId, out LearningSession session) => _items.TryGetValue(sessionId, out session!);
|
||
|
||
public StepCompareResult SubmitTeachingObservation(LearningSession session, List<SkillStep> observed, FeatureGrader grader)
|
||
{
|
||
session.ObservedSteps.AddRange(observed);
|
||
if (session.CurrentStepIndex >= session.LessonSteps.Count)
|
||
{
|
||
session.Status = "completed";
|
||
var completed = new StepCompareResult { Ok = true, Score = 100, FeedbackText = "教学步骤已经完成。" };
|
||
session.Reports.Add(completed);
|
||
return completed;
|
||
}
|
||
|
||
var expected = session.LessonSteps[session.CurrentStepIndex];
|
||
var actual = observed.LastOrDefault();
|
||
var result = grader.CompareStep(expected, actual);
|
||
session.Reports.Add(result);
|
||
if (result.Ok)
|
||
{
|
||
session.CurrentStepIndex++;
|
||
if (session.CurrentStepIndex >= session.LessonSteps.Count)
|
||
session.Status = "completed";
|
||
}
|
||
return result;
|
||
}
|
||
|
||
public StepCompareResult PollTeachingObservation(LearningSession session, List<SkillStep> observed, FeatureGrader grader)
|
||
{
|
||
if (session.CurrentStepIndex >= session.LessonSteps.Count)
|
||
{
|
||
session.Status = "completed";
|
||
return new StepCompareResult { Ok = true, Score = 100, FeedbackText = "教学步骤已经完成。" };
|
||
}
|
||
|
||
var expected = session.LessonSteps[session.CurrentStepIndex];
|
||
var actual = observed.LastOrDefault();
|
||
var result = grader.CompareStep(expected, actual);
|
||
if (!result.Ok)
|
||
return result;
|
||
|
||
session.ObservedSteps.AddRange(observed);
|
||
session.Reports.Add(result);
|
||
session.CurrentStepIndex++;
|
||
if (session.CurrentStepIndex >= session.LessonSteps.Count)
|
||
session.Status = "completed";
|
||
return result;
|
||
}
|
||
|
||
public void AcceptCurrentStepFromLiveState(LearningSession session, StepCompareResult result)
|
||
{
|
||
session.Reports.Add(result);
|
||
session.CurrentStepIndex++;
|
||
if (session.CurrentStepIndex >= session.LessonSteps.Count)
|
||
session.Status = "completed";
|
||
}
|
||
|
||
public StepCompareResult? CheckActiveDocumentForCurrentStep(LearningSession session, SolidWorksDocumentStatus documentStatus)
|
||
{
|
||
if (session.CurrentStepIndex >= session.LessonSteps.Count)
|
||
return null;
|
||
|
||
var expected = session.LessonSteps[session.CurrentStepIndex];
|
||
var expectsPartModeling = session.StandardId.StartsWith("part-", StringComparison.OrdinalIgnoreCase);
|
||
if (!expectsPartModeling)
|
||
return null;
|
||
|
||
if (!documentStatus.Connected)
|
||
return WrongDocumentResult(expected, "没有连接到 SolidWorks。请先启动 SolidWorks,然后按照语音提示继续。", documentStatus);
|
||
|
||
if (!documentStatus.HasDocument)
|
||
return WrongDocumentResult(expected, "还没有检测到打开的 SolidWorks 文档。当前步骤需要新建零件,请点击新建并选择零件。", documentStatus);
|
||
|
||
if (documentStatus.TypeName == "part")
|
||
{
|
||
var sketchResult = CheckActiveSketchForCurrentStep(expected, documentStatus);
|
||
if (sketchResult != null)
|
||
return sketchResult;
|
||
var liveCircleResult = CheckActiveSketchCircleForCurrentStep(expected, documentStatus);
|
||
if (liveCircleResult != null)
|
||
return liveCircleResult;
|
||
var exitSketchResult = CheckExitSketchForCurrentStep(expected, documentStatus);
|
||
if (exitSketchResult != null)
|
||
return exitSketchResult;
|
||
return null;
|
||
}
|
||
|
||
var message = documentStatus.TypeName switch
|
||
{
|
||
"assembly" => "你点错了。当前打开的是装配体,但本步骤要求新建零件。请关闭或切回正确窗口,点击新建,然后选择零件,不要选择装配体。",
|
||
"drawing" => "你点错了。当前打开的是工程图,但本步骤要求新建零件。请点击新建,然后选择零件。",
|
||
_ => $"当前活动文档类型不是零件,检测到的是 {documentStatus.TypeName}。本步骤要求在零件环境中操作。",
|
||
};
|
||
return WrongDocumentResult(expected, message, documentStatus);
|
||
}
|
||
|
||
static StepCompareResult? CheckActiveSketchForCurrentStep(LessonStep expected, SolidWorksDocumentStatus documentStatus)
|
||
{
|
||
var expectedReference = expected.ExpectedSkill switch
|
||
{
|
||
"create_front_plane_sketch" => "front",
|
||
"create_top_plane_sketch" => "top",
|
||
"create_right_plane_sketch" => "right",
|
||
_ => "",
|
||
};
|
||
|
||
if (string.IsNullOrWhiteSpace(expectedReference))
|
||
return null;
|
||
|
||
if (!documentStatus.ActiveSketch)
|
||
return WrongDocumentResult(expected, $"当前还没有进入草图编辑。请在 SolidWorks 中选择 {PlaneDisplayName(expectedReference)},然后点击草图。", documentStatus);
|
||
|
||
if (SketchReferenceMatches(documentStatus.ActiveSketchReferenceName, expectedReference))
|
||
{
|
||
return new StepCompareResult
|
||
{
|
||
Ok = true,
|
||
Score = 100,
|
||
ExpectedStep = expected,
|
||
FeedbackText = $"正确。你已经在 {PlaneDisplayName(expectedReference)} 进入草图,可以继续下一步。",
|
||
};
|
||
}
|
||
|
||
return WrongDocumentResult(
|
||
expected,
|
||
$"你进入了草图,但草图基准面不对。当前检测到的是 {DisplayOrUnknown(documentStatus.ActiveSketchReferenceName)},本步骤要求选择 {PlaneDisplayName(expectedReference)}。",
|
||
documentStatus);
|
||
}
|
||
|
||
static StepCompareResult? CheckActiveSketchCircleForCurrentStep(LessonStep expected, SolidWorksDocumentStatus documentStatus)
|
||
{
|
||
if (!string.Equals(expected.ExpectedSkill, "draw_circle_diameter_mm", StringComparison.OrdinalIgnoreCase))
|
||
return null;
|
||
|
||
if (!documentStatus.ActiveSketch)
|
||
return WrongLiveSketchResult(expected, "当前已经不在草图编辑状态。请重新进入当前草图,系统会实时检测圆和直径尺寸。", documentStatus, "not_editing_sketch");
|
||
|
||
var circles = documentStatus.ActiveSketchCircles.Where(circle => circle.IsFullCircle).ToList();
|
||
if (circles.Count == 0)
|
||
return WrongLiveSketchResult(expected, "还没有检测到完整圆。请在当前草图中使用圆命令绘制圆,不要用圆弧或样条替代。", documentStatus, "missing_circle");
|
||
|
||
if (!TryGetExpectedDouble(expected.ExpectedArguments, "diameter_mm", out var expectedDiameterMm))
|
||
{
|
||
var firstCircle = circles[0];
|
||
return CorrectLiveSketchResult(expected, firstCircle, "正确。已经检测到完整圆,可以继续下一步。");
|
||
}
|
||
|
||
var toleranceMm = Math.Max(0.05, expectedDiameterMm * 0.005);
|
||
var matchingCircle = circles
|
||
.OrderBy(circle => Math.Abs(circle.DiameterMm - expectedDiameterMm))
|
||
.FirstOrDefault(circle => Math.Abs(circle.DiameterMm - expectedDiameterMm) <= toleranceMm);
|
||
if (matchingCircle == null)
|
||
{
|
||
var detected = string.Join("、", circles.Select(circle => $"{circle.DiameterMm:0.###}mm"));
|
||
return WrongLiveSketchResult(
|
||
expected,
|
||
$"检测到圆,但直径不对。当前圆直径为 {detected},本步骤要求直径 {expectedDiameterMm:0.###}mm。请用智能尺寸把圆标注并修改到标准值。",
|
||
documentStatus,
|
||
"wrong_circle_diameter");
|
||
}
|
||
|
||
var hasMatchingDimension = documentStatus.ActiveSketchDimensions.Any(dimension => Math.Abs(dimension.ValueMm - expectedDiameterMm) <= toleranceMm);
|
||
var message = hasMatchingDimension
|
||
? $"正确。已经检测到直径 {expectedDiameterMm:0.###}mm 的圆和尺寸标注,可以退出草图。"
|
||
: $"正确。已经检测到直径 {expectedDiameterMm:0.###}mm 的圆,可以退出草图。";
|
||
return CorrectLiveSketchResult(expected, matchingCircle, message);
|
||
}
|
||
|
||
static StepCompareResult? CheckExitSketchForCurrentStep(LessonStep expected, SolidWorksDocumentStatus documentStatus)
|
||
{
|
||
if (!string.Equals(expected.ExpectedSkill, "exit_sketch", StringComparison.OrdinalIgnoreCase))
|
||
return null;
|
||
|
||
if (documentStatus.ActiveSketch)
|
||
return WrongLiveSketchResult(expected, "圆已经完成。现在请点击左上角“退出草图”,让草图进入后续特征建模流程。", documentStatus, "still_editing_sketch");
|
||
|
||
return new StepCompareResult
|
||
{
|
||
Ok = true,
|
||
Score = 100,
|
||
ExpectedStep = expected,
|
||
ObservedStep = new SkillStep
|
||
{
|
||
Skill = "exit_sketch",
|
||
Name = expected.Title,
|
||
Description = "已退出草图编辑",
|
||
},
|
||
FeedbackText = "正确。已经退出草图,可以继续下一步特征操作。",
|
||
};
|
||
}
|
||
|
||
static StepCompareResult CorrectLiveSketchResult(LessonStep expected, SketchCircleInfo circle, string message)
|
||
{
|
||
return new StepCompareResult
|
||
{
|
||
Ok = true,
|
||
Score = 100,
|
||
ExpectedStep = expected,
|
||
ObservedStep = new SkillStep
|
||
{
|
||
Skill = "draw_circle_diameter_mm",
|
||
Name = expected.Title,
|
||
Description = "活动草图实时检测到圆",
|
||
Arguments = new Dictionary<string, object?>
|
||
{
|
||
["diameter_mm"] = circle.DiameterMm,
|
||
["cx_mm"] = circle.CenterXLocalMm,
|
||
["cy_mm"] = circle.CenterYLocalMm,
|
||
},
|
||
},
|
||
FeedbackText = message,
|
||
};
|
||
}
|
||
|
||
static StepCompareResult WrongLiveSketchResult(LessonStep expected, string message, SolidWorksDocumentStatus documentStatus, string code)
|
||
{
|
||
return new StepCompareResult
|
||
{
|
||
Ok = false,
|
||
Score = 0,
|
||
ExpectedStep = expected,
|
||
FeedbackText = message,
|
||
Issues = new List<CompareIssue>
|
||
{
|
||
new()
|
||
{
|
||
Code = code,
|
||
Message = message,
|
||
Expected = new Dictionary<string, object?>
|
||
{
|
||
["skill"] = expected.ExpectedSkill,
|
||
["arguments"] = expected.ExpectedArguments,
|
||
},
|
||
Actual = new Dictionary<string, object?>
|
||
{
|
||
["active_sketch"] = documentStatus.ActiveSketch,
|
||
["circles"] = documentStatus.ActiveSketchCircles,
|
||
["dimensions"] = documentStatus.ActiveSketchDimensions,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
static bool TryGetExpectedDouble(Dictionary<string, object?> args, string key, out double value)
|
||
{
|
||
value = 0;
|
||
if (!args.TryGetValue(key, out var raw) || raw == null)
|
||
return false;
|
||
|
||
try
|
||
{
|
||
if (raw is JsonElement element)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out value))
|
||
return true;
|
||
if (element.ValueKind == JsonValueKind.String && double.TryParse(element.GetString(), out value))
|
||
return true;
|
||
return false;
|
||
}
|
||
|
||
value = Convert.ToDouble(raw);
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
return raw is string text && double.TryParse(text, out value);
|
||
}
|
||
}
|
||
|
||
static bool SketchReferenceMatches(string referenceName, string expectedReference)
|
||
{
|
||
var text = referenceName.ToLowerInvariant();
|
||
return expectedReference switch
|
||
{
|
||
"front" => text.Contains("front") || referenceName.Contains("前视"),
|
||
"top" => text.Contains("top") || referenceName.Contains("上视"),
|
||
"right" => text.Contains("right") || referenceName.Contains("右视"),
|
||
_ => false,
|
||
};
|
||
}
|
||
|
||
static string PlaneDisplayName(string reference)
|
||
{
|
||
return reference switch
|
||
{
|
||
"front" => "前视基准面",
|
||
"top" => "上视基准面",
|
||
"right" => "右视基准面",
|
||
_ => "指定基准面",
|
||
};
|
||
}
|
||
|
||
static string DisplayOrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? "未识别的基准面" : value;
|
||
|
||
static StepCompareResult WrongDocumentResult(LessonStep expected, string message, SolidWorksDocumentStatus documentStatus)
|
||
{
|
||
return new StepCompareResult
|
||
{
|
||
Ok = false,
|
||
Score = 0,
|
||
ExpectedStep = expected,
|
||
FeedbackText = message,
|
||
Issues = new List<CompareIssue>
|
||
{
|
||
new()
|
||
{
|
||
Code = "wrong_solidworks_document_type",
|
||
Message = message,
|
||
Expected = new Dictionary<string, object?>
|
||
{
|
||
["document_type"] = "part",
|
||
["skill"] = expected.ExpectedSkill,
|
||
},
|
||
Actual = new Dictionary<string, object?>
|
||
{
|
||
["connected"] = documentStatus.Connected,
|
||
["has_document"] = documentStatus.HasDocument,
|
||
["document_type"] = documentStatus.TypeName,
|
||
["title"] = documentStatus.Title,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
public GradeReport SubmitTest(LearningSession session, List<SkillStep> observed, FeatureGrader grader)
|
||
{
|
||
session.ObservedSteps = observed;
|
||
var report = grader.GradeSequence(session.LessonSteps, observed);
|
||
session.FinalReport = report;
|
||
session.Status = "completed";
|
||
return report;
|
||
}
|
||
}
|
||
|
||
sealed class SolidWorksExtractor
|
||
{
|
||
readonly AppPaths _paths;
|
||
|
||
public SolidWorksExtractor(AppPaths paths)
|
||
{
|
||
_paths = paths;
|
||
}
|
||
|
||
public Task<List<SkillStep>> AuditActivePartAsync()
|
||
{
|
||
var outputDir = Path.Combine(_paths.RuntimeDir, "audits", "active-part");
|
||
Directory.CreateDirectory(outputDir);
|
||
return RunPartAuditAsync(new[] { "--active", "--output-dir", outputDir }, outputDir);
|
||
}
|
||
|
||
public Task<List<SkillStep>> AuditPartAsync(string partPath)
|
||
{
|
||
if (!File.Exists(partPath))
|
||
throw new FileNotFoundException(partPath);
|
||
|
||
var outputDir = Path.Combine(_paths.RuntimeDir, "audits", Path.GetFileNameWithoutExtension(partPath));
|
||
Directory.CreateDirectory(outputDir);
|
||
return RunPartAuditAsync(new[] { partPath, "--output-dir", outputDir }, outputDir);
|
||
}
|
||
|
||
async Task<List<SkillStep>> RunPartAuditAsync(string[] args, string outputDir)
|
||
{
|
||
var startInfo = new ProcessStartInfo
|
||
{
|
||
FileName = "dotnet",
|
||
WorkingDirectory = _paths.Agent4Root,
|
||
UseShellExecute = false,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
};
|
||
startInfo.ArgumentList.Add("run");
|
||
startInfo.ArgumentList.Add("--project");
|
||
startInfo.ArgumentList.Add(_paths.PartFeatureAuditProject);
|
||
startInfo.ArgumentList.Add("--");
|
||
foreach (var arg in args)
|
||
startInfo.ArgumentList.Add(arg);
|
||
|
||
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start PartFeatureAudit.");
|
||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||
var stderr = await process.StandardError.ReadToEndAsync();
|
||
await process.WaitForExitAsync();
|
||
|
||
if (process.ExitCode != 0)
|
||
throw new InvalidOperationException($"PartFeatureAudit failed. exit={process.ExitCode}\n{stdout}\n{stderr}");
|
||
|
||
return ReadLatestSteps(outputDir);
|
||
}
|
||
|
||
static List<SkillStep> ReadLatestSteps(string outputDir)
|
||
{
|
||
var candidates = Directory.EnumerateFiles(outputDir, "*_modeling_plan.json")
|
||
.Concat(Directory.EnumerateFiles(outputDir, "*_skill_flow.json"))
|
||
.Select(path => new FileInfo(path))
|
||
.OrderByDescending(file => file.LastWriteTimeUtc)
|
||
.ToList();
|
||
|
||
if (candidates.Count == 0)
|
||
throw new FileNotFoundException($"No audit JSON generated under {outputDir}");
|
||
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(candidates[0].FullName));
|
||
return SkillFlow.NormalizeSteps(doc.RootElement);
|
||
}
|
||
}
|
||
|
||
sealed class StartSessionRequest
|
||
{
|
||
public string StandardId { get; set; } = "";
|
||
}
|
||
|
||
sealed class ObservationRequest
|
||
{
|
||
public List<Dictionary<string, object?>> Steps { get; set; } = new();
|
||
}
|
||
|
||
sealed class AuditPartRequest
|
||
{
|
||
public string PartPath { get; set; } = "";
|
||
}
|
||
|
||
sealed class SubmitTestRequest
|
||
{
|
||
public List<Dictionary<string, object?>> Steps { get; set; } = new();
|
||
public string PartPath { get; set; } = "";
|
||
}
|
||
|
||
sealed class SkillStep
|
||
{
|
||
public string Id { get; set; } = "";
|
||
public int Order { get; set; }
|
||
public string Skill { get; set; } = "";
|
||
public string Name { get; set; } = "";
|
||
public string Description { get; set; } = "";
|
||
public Dictionary<string, object?> Arguments { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||
public string SourceFeature { get; set; } = "";
|
||
public string Note { get; set; } = "";
|
||
}
|
||
|
||
sealed class StandardAnswer
|
||
{
|
||
public string StandardId { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public string Kind { get; set; } = "unknown";
|
||
public string SourcePath { get; set; } = "";
|
||
public string SourceType { get; set; } = "skillflow";
|
||
public List<string> Tags { get; set; } = new();
|
||
public string Summary { get; set; } = "";
|
||
public List<SkillStep> Steps { get; set; } = new();
|
||
}
|
||
|
||
sealed class LessonStep
|
||
{
|
||
public string StepId { get; set; } = "";
|
||
public int Order { get; set; }
|
||
public string Title { get; set; } = "";
|
||
public string VoiceText { get; set; } = "";
|
||
public string ExpectedSkill { get; set; } = "";
|
||
public Dictionary<string, object?> ExpectedArguments { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||
public string SourceFeature { get; set; } = "";
|
||
public string TeachingIntent { get; set; } = "";
|
||
public string CommonMistake { get; set; } = "";
|
||
public Dictionary<string, object?> CheckPolicy { get; set; } = new();
|
||
}
|
||
|
||
sealed class CompareIssue
|
||
{
|
||
public string Severity { get; set; } = "error";
|
||
public string Code { get; set; } = "";
|
||
public string Message { get; set; } = "";
|
||
public Dictionary<string, object?> Expected { get; set; } = new();
|
||
public Dictionary<string, object?> Actual { get; set; } = new();
|
||
}
|
||
|
||
sealed class StepCompareResult
|
||
{
|
||
public bool Ok { get; set; }
|
||
public double Score { get; set; }
|
||
public LessonStep? ExpectedStep { get; set; }
|
||
public SkillStep? ObservedStep { get; set; }
|
||
public List<CompareIssue> Issues { get; set; } = new();
|
||
public string FeedbackText { get; set; } = "";
|
||
}
|
||
|
||
sealed class GradeReport
|
||
{
|
||
public bool Ok { get; set; }
|
||
public double Score { get; set; }
|
||
public double MaxScore { get; set; } = 100.0;
|
||
public int PassedSteps { get; set; }
|
||
public int TotalSteps { get; set; }
|
||
public List<CompareIssue> Issues { get; set; } = new();
|
||
public string Summary { get; set; } = "";
|
||
}
|
||
|
||
sealed class LearningSession
|
||
{
|
||
public string SessionId { get; set; } = "";
|
||
public string Mode { get; set; } = "";
|
||
public string StandardId { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public int CurrentStepIndex { get; set; }
|
||
public DateTime CreatedAt { get; set; }
|
||
public List<LessonStep> LessonSteps { get; set; } = new();
|
||
public List<SkillStep> ObservedSteps { get; set; } = new();
|
||
public List<StepCompareResult> Reports { get; set; } = new();
|
||
public GradeReport? FinalReport { get; set; }
|
||
public string Status { get; set; } = "active";
|
||
}
|
||
|