using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

var root = LocateRepoRoot();
if (args.Length == 0 || IsHelp(args[0]))
{
    PrintUsage();
    return 1;
}

Console.OutputEncoding = Encoding.UTF8;

const string SectionBrepExtractorProject = "tools\\model-diagnostics\\SectionBrepExtractor\\SectionBrepExtractor.csproj";

var mode = args[0].Trim().ToLowerInvariant();
var tail = args.Skip(1).ToArray();

try
{
    return mode switch
    {
        "model" => RunChild(root, SectionBrepExtractorProject, tail),
        "bridge" => RunSemanticBridge(root, tail),
        "interpret" => RunSemanticBridge(root, tail),
        "aggregate-existing" => RunAggregateExisting(root, tail),
        "finalize-interfaces-existing" => RunFinalizeInterfacesExisting(root, tail),
        "diagnose-ch18" => Fail("diagnose-ch18 is a legacy trial path and is disabled. Use the Agent4 backend /api/mechanical-diagnostics/diagnose flow."),
        _ => Fail($"Unknown mode: {mode}")
    };
}
catch (Exception ex)
{
    Console.Error.WriteLine(ex.Message);
    return 1;
}

static int RunSemanticBridge(string root, string[] args)
{
    if (args.Length < 1)
        return Fail("bridge requires an assembly path.");

    string asmPath = Path.GetFullPath(args[0]);
    string outputDir = Path.Combine(root, "runtime", "brep_semantic_bridge", SanitizeFileName(Path.GetFileNameWithoutExtension(asmPath)));
    bool callAi = true;
    bool deep = false;
    int maxIterations = 1;
    string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "qwen-vl-max";
    string productDescription = "";
    string partFunctionProfile = "";
    var purchasedComponentHints = new List<string>();

    for (int i = 1; i < args.Length; i++)
    {
        string arg = args[i];
        if (arg.Equals("--no-ai", StringComparison.OrdinalIgnoreCase))
        {
            callAi = false;
        }
        else if (arg.Equals("--deep", StringComparison.OrdinalIgnoreCase))
        {
            deep = true;
        }
        else if (arg.Equals("--max-iterations", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
        {
            maxIterations = Math.Max(1, int.Parse(args[++i], CultureInfo.InvariantCulture));
        }
        else if (arg.StartsWith("--max-iterations=", StringComparison.OrdinalIgnoreCase))
        {
            maxIterations = Math.Max(1, int.Parse(arg["--max-iterations=".Length..], CultureInfo.InvariantCulture));
        }
        else if (arg.Equals("--model", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
        {
            model = args[++i];
        }
        else if (arg.StartsWith("--model=", StringComparison.OrdinalIgnoreCase))
        {
            model = arg["--model=".Length..];
        }
        else if (arg.Equals("--product-description", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
        {
            productDescription = args[++i];
        }
        else if (arg.StartsWith("--product-description=", StringComparison.OrdinalIgnoreCase))
        {
            productDescription = arg["--product-description=".Length..];
        }
        else if (arg.Equals("--part-function-profile", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
        {
            partFunctionProfile = args[++i];
        }
        else if (arg.StartsWith("--part-function-profile=", StringComparison.OrdinalIgnoreCase))
        {
            partFunctionProfile = arg["--part-function-profile=".Length..];
        }
        else if (arg.Equals("--purchased-component-hint", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
        {
            purchasedComponentHints.Add(args[++i]);
        }
        else if (arg.StartsWith("--purchased-component-hint=", StringComparison.OrdinalIgnoreCase))
        {
            purchasedComponentHints.Add(arg["--purchased-component-hint=".Length..]);
        }
        else if (arg.Equals("--purchased-component-hints", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
        {
            purchasedComponentHints.AddRange(SplitUserHintList(args[++i]));
        }
        else if (arg.StartsWith("--purchased-component-hints=", StringComparison.OrdinalIgnoreCase))
        {
            purchasedComponentHints.AddRange(SplitUserHintList(arg["--purchased-component-hints=".Length..]));
        }
        else if (!arg.StartsWith("--", StringComparison.OrdinalIgnoreCase))
        {
            outputDir = Path.GetFullPath(arg);
        }
        else
        {
            return Fail($"Unknown bridge option: {arg}");
        }
    }

    Directory.CreateDirectory(outputDir);
    string extractionDir = Path.Combine(outputDir, "extractions");
    string primaryExtractionDir = Path.Combine(extractionDir, "primary_y");
    Directory.CreateDirectory(primaryExtractionDir);

    var purchasedHints = purchasedComponentHints
        .Select(hint => hint.Trim())
        .Where(hint => !string.IsNullOrWhiteSpace(hint))
        .Distinct(StringComparer.OrdinalIgnoreCase)
        .ToList();
    var extractionArgs = new List<string> { asmPath, "0", "1", "0", "--output-dir", primaryExtractionDir, "--export-images" };
    foreach (var hint in purchasedHints)
    {
        extractionArgs.Add("--purchased-component-hint");
        extractionArgs.Add(hint);
        extractionArgs.Add("--external-interface-root");
        extractionArgs.Add(hint);
    }

    var steps = new List<StepResult>
    {
        RunProjectWithCapture(root, SectionBrepExtractorProject, extractionArgs),
        RunProjectWithCapture(root, "tools\\model-diagnostics\\StructuralFaultProbe\\StructuralFaultProbe.csproj", [asmPath])
    };

    string sectionReportPath = Path.Combine(primaryExtractionDir, "section_brep_report.json");
    string structuralEvidencePath = Path.Combine(root, "runtime", "structural_fault_probe", "structural_fault_evidence.json");
    if (!File.Exists(sectionReportPath))
        return Fail($"Section B-rep report not found after extraction: {sectionReportPath}");

    var sectionReport = JsonSerializer.Deserialize<SectionBrepReport>(File.ReadAllText(sectionReportPath), JsonOptions.Default)
        ?? throw new InvalidOperationException("Cannot parse section B-rep report.");
    if (!sectionReport.Ok)
        return Fail($"Section B-rep extraction failed for this model. report={sectionReportPath}; message={sectionReport.Message}");
    if (!SameFullPath(sectionReport.Path, asmPath))
        return Fail($"Section B-rep report path does not match current model. expected={asmPath}; actual={sectionReport.Path}; report={sectionReportPath}");

    var partCheckManifestPath = WriteComponentPartCheckReuseManifest(sectionReportPath, outputDir);

    var sectionViews = new List<SectionViewReport>
    {
        new()
        {
            Id = "primary_y",
            GuideAxis = [0, 1, 0],
            ReportPath = sectionReportPath,
            Report = sectionReport
        }
    };

    using var structuralDoc = File.Exists(structuralEvidencePath)
        ? JsonDocument.Parse(File.ReadAllText(structuralEvidencePath))
        : JsonDocument.Parse("[]");

    bool needsMoreLocalFacts = false;
    if (needsMoreLocalFacts && maxIterations > 1)
    {
        foreach (var view in new[]
        {
            new SectionAxisRequest("secondary_x", [1.0, 0.0, 0.0]),
            new SectionAxisRequest("secondary_z", [0.0, 0.0, 1.0])
        })
        {
            var step = RunProjectWithCapture(root, SectionBrepExtractorProject,
                [
                    asmPath,
                    FormatAxis(view.GuideAxis[0]),
                    FormatAxis(view.GuideAxis[1]),
                    FormatAxis(view.GuideAxis[2]),
                    "--output-dir",
                    Path.Combine(extractionDir, view.Id),
                    "--export-images"
                ]);
            steps.Add(step);

            string viewPath = Path.Combine(extractionDir, view.Id, "section_brep_report.json");
            if (!File.Exists(viewPath))
                continue;

            var viewReport = JsonSerializer.Deserialize<SectionBrepReport>(File.ReadAllText(viewPath), JsonOptions.Default);
            if (viewReport != null && viewReport.Ok && SameFullPath(viewReport.Path, asmPath))
            {
                sectionViews.Add(new SectionViewReport
                {
                    Id = view.Id,
                    GuideAxis = view.GuideAxis,
                    ReportPath = viewPath,
                    Report = viewReport
                });
            }
        }
    }

    var artifact = SemanticBridgeBuilder.WriteArtifacts(
        asmPath,
        outputDir,
        sectionViews,
        structuralDoc.RootElement,
        structuralEvidencePath,
        new UserDiagnosticContext(productDescription.Trim(), purchasedHints, partFunctionProfile.Trim()));

    AiCallResult ai = callAi
        ? OpenAiInterpreter.TryInterpret(root, SectionBrepExtractorProject, model, artifact.PromptText, outputDir, maxIterations, artifact.StructureGraphPath).GetAwaiter().GetResult()
        : new AiCallResult
        {
            Status = "skipped",
            Model = model,
            Message = "AI call disabled by --no-ai. Generated prompt.md, semantic_bridge_package.json, and mechanical_semantic_graph_contract.json instead.",
            MechanicalSemanticGraphStatus = "not_generated_no_ai"
        };

    var manifest = new
    {
        Mode = "bridge",
        AssemblyPath = asmPath,
        OutputDir = outputDir,
        DeepRequested = deep,
        SectionPolicy = "section workflow disabled; use assembly/component B-rep, standard images, component highlight images, and internal component group context images",
        Timestamp = DateTimeOffset.Now,
        Steps = steps.Select(s => new
        {
            s.Project,
            s.ExitCode,
            Output = Summarize(s.StandardOutput),
            Error = Summarize(s.StandardError)
        }),
        Artifacts = new
        {
            artifact.StructureGraphPath,
            artifact.PatchMapPath,
            artifact.RenderSvgPath,
            artifact.LlmInputPath,
            artifact.PromptPath,
            artifact.MechanicalSemanticGraphContractPath,
            artifact.SelfEvolutionAuditPath,
            ComponentPartCheckManifestPath = partCheckManifestPath,
            AiResultPath = ai.ResultPath,
            AiRawResponsePath = ai.RawResponsePath,
            AiSelfEvolutionPath = ai.SelfEvolutionPath,
            AiMechanicalSemanticGraphPath = ai.MechanicalSemanticGraphPath
        },
        SelfEvolution = artifact.Audit,
        Ai = new
        {
            ai.Status,
            ai.Model,
            ai.Message,
            ai.MechanicalSemanticGraphStatus
        }
    };

    string manifestPath = Path.Combine(outputDir, "semantic_bridge_manifest.json");
    Directory.CreateDirectory(outputDir);
    File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, JsonOptions.Default), new UTF8Encoding(false));

    Console.WriteLine(JsonSerializer.Serialize(manifest, JsonOptions.Default));
    return steps.All(s => s.ExitCode == 0) && ai.Status != "error" ? 0 : 1;
}

static int RunAggregateExisting(string root, string[] args)
{
    return OpenAiInterpreter.AggregateExisting(root, args);
}

static int RunFinalizeInterfacesExisting(string root, string[] args)
{
    return OpenAiInterpreter.FinalizeInterfacesExisting(root, args);
}

static IEnumerable<string> SplitUserHintList(string value) =>
    value.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

static string WriteComponentPartCheckReuseManifest(string sectionReportPath, string outputDir)
{
    var partCheckRoot = Path.Combine(outputDir, "part_check_extractions");
    Directory.CreateDirectory(partCheckRoot);

    using var doc = JsonDocument.Parse(File.ReadAllText(sectionReportPath));
    var entries = new List<object>();
    if (!doc.RootElement.TryGetProperty("ComponentEvidencePackages", out var packages) ||
        packages.ValueKind != JsonValueKind.Array)
    {
        return WritePartCheckManifest(partCheckRoot, sectionReportPath, entries);
    }
    var features = doc.RootElement.TryGetProperty("FeatureGraph", out var graph) &&
                   graph.ValueKind == JsonValueKind.Object &&
                   graph.TryGetProperty("Features", out var graphFeatures) &&
                   graphFeatures.ValueKind == JsonValueKind.Array
        ? graphFeatures.EnumerateArray().Select(item => item.Clone()).ToList()
        : [];
    var graphRelations = doc.RootElement.TryGetProperty("FeatureGraph", out graph) &&
                         graph.ValueKind == JsonValueKind.Object &&
                         graph.TryGetProperty("Relations", out var relations) &&
                         relations.ValueKind == JsonValueKind.Array
        ? relations.EnumerateArray().Select(item => item.Clone()).ToList()
        : [];
    var faces = doc.RootElement.TryGetProperty("AssemblyFaces", out var assemblyFaces) &&
                assemblyFaces.ValueKind == JsonValueKind.Array
        ? assemblyFaces.EnumerateArray().Select(item => item.Clone()).ToList()
        : [];
    var imageViews = doc.RootElement.TryGetProperty("ImageExport", out var imageExport) &&
                     imageExport.ValueKind == JsonValueKind.Object &&
                     imageExport.TryGetProperty("Views", out var views) &&
                     views.ValueKind == JsonValueKind.Array
        ? views.EnumerateArray().Select(item => item.Clone()).ToList()
        : [];

    foreach (var package in packages.EnumerateArray())
    {
        if (!JsonString(package, "ReviewScope").Equals("part_design_required", StringComparison.OrdinalIgnoreCase))
            continue;

        var componentId = JsonString(package, "ComponentId");
        var componentDir = Path.Combine(partCheckRoot, SanitizeFileName(FirstNonEmpty(componentId, "component")));
        Directory.CreateDirectory(componentDir);
        entries.Add(BuildPartCheckReuseManifestEntry(package, componentDir, sectionReportPath, features, graphRelations, faces, imageViews));
    }

    return WritePartCheckManifest(partCheckRoot, sectionReportPath, entries);
}

static string WritePartCheckManifest(string partCheckRoot, string sourceAssemblyReportPath, IReadOnlyList<object> entries)
{
    var manifestPath = Path.Combine(partCheckRoot, "manifest.json");
    File.WriteAllText(manifestPath, JsonSerializer.Serialize(new
    {
        schema = "component_part_check_extractions_manifest_v1",
        source_assembly_report_path = sourceAssemblyReportPath,
        extraction_policy = "No second B-rep extraction is performed here. Component part-check evidence is reused and filtered from the initial assembly SectionBrepExtractor report.",
        image_path_policy = "Part-check evidence keeps a separate logical manifest under part_check_extractions/<component_id>, but image files are reused from the initial extraction output instead of being regenerated.",
        brep_policy = "Reuse assembly-derived component B-rep faces/features/images for part-level checks; do not rerun SectionBrepExtractor per component and do not replace transformed assembly contact/mate facts.",
        component_count = entries.Count,
        components = entries
    }, JsonOptions.Default), new UTF8Encoding(false));
    return manifestPath;
}

static object BuildPartCheckReuseManifestEntry(
    JsonElement package,
    string componentDir,
    string sourceAssemblyReportPath,
    IReadOnlyList<JsonElement> allFeatures,
    IReadOnlyList<JsonElement> allFeatureRelations,
    IReadOnlyList<JsonElement> allFaces,
    IReadOnlyList<JsonElement> allImageViews)
{
    var componentId = JsonString(package, "ComponentId");
    var featureIds = JsonStringArray(package, "FeatureIds").ToHashSet(StringComparer.OrdinalIgnoreCase);
    var faceRefs = JsonStringArray(package, "FaceRefs").ToHashSet(StringComparer.OrdinalIgnoreCase);
    var partImagePlanId = JsonString(package, "PartImagePlanId");
    var assemblyContextImagePlanId = JsonString(package, "AssemblyContextImagePlanId");
    var componentFeatures = allFeatures
        .Where(feature => featureIds.Contains(JsonString(feature, "Id")) ||
                          featureIds.Count == 0 && ComponentPackageMatches(package, JsonString(feature, "ComponentName"), JsonString(feature, "ComponentPath")))
        .Select(feature => feature.Clone())
        .ToList();
    var componentFeatureIds = componentFeatures
        .Select(feature => JsonString(feature, "Id"))
        .Where(id => !string.IsNullOrWhiteSpace(id))
        .ToHashSet(StringComparer.OrdinalIgnoreCase);
    var componentFaces = allFaces
        .Where(face => faceRefs.Contains(TopLevelFaceEvidenceRef(face)) ||
                       faceRefs.Count == 0 && ComponentPackageMatches(package, JsonString(face, "ComponentName"), JsonString(face, "ComponentPath")))
        .Select(face => face.Clone())
        .ToList();
    var componentImageViews = allImageViews
        .Where(image => ImageBelongsToPartCheckEvidence(image, componentId, partImagePlanId, assemblyContextImagePlanId, componentFeatureIds, faceRefs))
        .Select(image => image.Clone())
        .ToList();
    var componentRelations = allFeatureRelations
        .Where(relation => JsonElementMentionsAny(relation, componentFeatureIds))
        .Select(relation => relation.Clone())
        .ToList();
    var featureGraph = JsonSerializer.SerializeToElement(new
    {
        schema = "reused_component_feature_graph_subset_v1",
        source = "initial_assembly_extraction.FeatureGraph",
        features = componentFeatures,
        relations = componentRelations
    }, JsonOptions.Default);
    var faces = JsonSerializer.SerializeToElement(componentFaces, JsonOptions.Default);
    var imageViews = JsonSerializer.SerializeToElement(componentImageViews, JsonOptions.Default);
    var evidencePath = Path.Combine(componentDir, "reused_part_check_evidence.json");

    var entry = new
    {
        component_id = componentId,
        instance_name = JsonString(package, "InstanceName"),
        display_name = JsonString(package, "DisplayName"),
        component_path = JsonString(package, "Path"),
        status = "ok",
        message = "Part-check evidence was reused from the initial assembly extraction. No per-component B-rep extraction was executed.",
        output_dir = componentDir,
        source_assembly_report_path = sourceAssemblyReportPath,
        evidence_path = evidencePath,
        image_namespace = $"part_check_extractions/{SanitizeFileName(FirstNonEmpty(componentId, "component"))}/reused_image_refs",
        source_image_namespace = "extractions/primary_y/model_images",
        reuse_policy = "Logical part-check evidence is separated in this manifest, but B-rep and images are filtered from the first extraction result.",
        feature_graph = featureGraph,
        faces,
        image_views = imageViews,
        counts = new
        {
            features = componentFeatures.Count,
            faces = componentFaces.Count,
            image_views = componentImageViews.Count
        }
    };
    File.WriteAllText(evidencePath, JsonSerializer.Serialize(entry, JsonOptions.Default), new UTF8Encoding(false));
    return entry;
}

static IEnumerable<string> JsonStringArray(JsonElement element, string property)
{
    if (element.ValueKind != JsonValueKind.Object ||
        !element.TryGetProperty(property, out var value) ||
        value.ValueKind != JsonValueKind.Array)
    {
        yield break;
    }

    foreach (var item in value.EnumerateArray())
    {
        if (item.ValueKind == JsonValueKind.String)
            yield return item.GetString() ?? "";
    }
}

static bool ComponentPackageMatches(JsonElement package, string componentName, string componentPath)
{
    var instanceName = JsonString(package, "InstanceName");
    var displayName = JsonString(package, "DisplayName");
    var packagePath = JsonString(package, "Path");
    return (!string.IsNullOrWhiteSpace(componentName) &&
            (componentName.Equals(instanceName, StringComparison.OrdinalIgnoreCase) ||
             componentName.Equals(displayName, StringComparison.OrdinalIgnoreCase))) ||
           (!string.IsNullOrWhiteSpace(packagePath) &&
            !string.IsNullOrWhiteSpace(componentPath) &&
            Path.GetFullPath(packagePath).Equals(Path.GetFullPath(componentPath), StringComparison.OrdinalIgnoreCase));
}

static string TopLevelFaceEvidenceRef(JsonElement face)
{
    var componentName = JsonString(face, "ComponentName");
    var faceIndex = JsonInt(face, "FaceIndex");
    return string.IsNullOrWhiteSpace(componentName) ? "" : $"{componentName}:face#{faceIndex}";
}

static int JsonInt(JsonElement element, string property) =>
    element.ValueKind == JsonValueKind.Object &&
    element.TryGetProperty(property, out var value) &&
    value.ValueKind == JsonValueKind.Number &&
    value.TryGetInt32(out var number)
        ? number
        : 0;

static bool ImageBelongsToPartCheckEvidence(
    JsonElement image,
    string componentId,
    string partImagePlanId,
    string assemblyContextImagePlanId,
    IReadOnlySet<string> featureIds,
    IReadOnlySet<string> faceRefs)
{
    var text = string.Join(" ", new[]
    {
        JsonString(image, "HighlightComponentId"),
        JsonString(image, "HighlightPlanId"),
        JsonString(image, "OutputPath"),
        JsonString(image, "ViewName"),
        JsonString(image, "HighlightFeatureId")
    });
    if (!string.IsNullOrWhiteSpace(componentId) && text.Contains(componentId, StringComparison.OrdinalIgnoreCase))
        return true;
    if (!string.IsNullOrWhiteSpace(partImagePlanId) && text.Contains(partImagePlanId, StringComparison.OrdinalIgnoreCase))
        return true;
    if (!string.IsNullOrWhiteSpace(assemblyContextImagePlanId) && text.Contains(assemblyContextImagePlanId, StringComparison.OrdinalIgnoreCase))
        return true;
    var featureId = JsonString(image, "HighlightFeatureId");
    if (!string.IsNullOrWhiteSpace(featureId) && featureIds.Contains(featureId))
        return true;
    var imageFaceRefs = JsonStringArray(image, "HighlightFaceRefs")
        .Concat(JsonStringArray(image, "HighlightBlockingFaceRefs"))
        .ToHashSet(StringComparer.OrdinalIgnoreCase);
    return imageFaceRefs.Count > 0 && imageFaceRefs.Overlaps(faceRefs);
}

static bool JsonElementMentionsAny(JsonElement element, IReadOnlySet<string> values)
{
    if (values.Count == 0)
        return false;
    var text = element.GetRawText();
    return values.Any(value => !string.IsNullOrWhiteSpace(value) && text.Contains(value, StringComparison.OrdinalIgnoreCase));
}

static string JsonString(JsonElement element, string property) =>
    element.ValueKind == JsonValueKind.Object &&
    element.TryGetProperty(property, out var value) &&
    value.ValueKind == JsonValueKind.String
        ? value.GetString() ?? ""
        : "";

static string FirstNonEmpty(params string?[] values) =>
    values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";

static string FormatAxis(double value)
{
    return value.ToString("0.###", CultureInfo.InvariantCulture);
}

static bool SameFullPath(string a, string b)
{
    if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
        return false;
    return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
        Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
        StringComparison.OrdinalIgnoreCase);
}

static int RunChild(string root, string projectRelativePath, string[] forwardedArgs)
{
    var step = RunProjectWithCapture(root, projectRelativePath, forwardedArgs);
    Console.WriteLine(step.StandardOutput);
    if (!string.IsNullOrWhiteSpace(step.StandardError))
        Console.Error.WriteLine(step.StandardError);
    return step.ExitCode;
}

static StepResult RunProjectWithCapture(string root, string projectRelativePath, IReadOnlyList<string> forwardedArgs)
{
    string projectPath = Path.Combine(root, projectRelativePath);
    if (!File.Exists(projectPath))
        throw new FileNotFoundException($"Project not found: {projectPath}");

    var psi = new ProcessStartInfo("dotnet")
    {
        WorkingDirectory = root,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
        CreateNoWindow = true
    };
    psi.ArgumentList.Add("run");
    psi.ArgumentList.Add("--project");
    psi.ArgumentList.Add(projectPath);
    psi.ArgumentList.Add("-c");
    psi.ArgumentList.Add("Release");
    psi.ArgumentList.Add("--");
    foreach (var arg in forwardedArgs)
        psi.ArgumentList.Add(arg);

    using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start project: {projectRelativePath}");
    string stdout = process.StandardOutput.ReadToEnd();
    string stderr = process.StandardError.ReadToEnd();
    process.WaitForExit();

    return new StepResult
    {
        Project = projectRelativePath,
        ExitCode = process.ExitCode,
        StandardOutput = stdout,
        StandardError = stderr
    };
}

static string Summarize(string text)
{
    if (string.IsNullOrWhiteSpace(text))
        return "";
    var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
    return string.Join(Environment.NewLine, lines.Take(20));
}

static bool IsHelp(string value)
{
    return value is "-h" or "--help" or "/?" or "help";
}

static void PrintUsage()
{
    Console.WriteLine("""
BrepFaultProbe

Usage:
  BrepFaultProbe model <assemblyPath>
  BrepFaultProbe bridge <assemblyPath> [outputDir] [--model <model>] [--no-ai]
  BrepFaultProbe interpret <assemblyPath> [outputDir] [--model <model>] [--no-ai]
  BrepFaultProbe finalize-interfaces-existing <outputDir> [--stage context|anchor|group|export|all] [--model <model>] [--max-images <n>]
""");
}

static string LocateRepoRoot()
{
    var starts = new[]
    {
        AppContext.BaseDirectory,
        Environment.CurrentDirectory
    };

    foreach (var start in starts)
    {
        var dir = new DirectoryInfo(Path.GetFullPath(start));
        while (dir != null)
        {
            if ((File.Exists(Path.Combine(dir.FullName, "project_paths.json")) ||
                 File.Exists(Path.Combine(dir.FullName, "SWagent_interact.slnx"))) &&
                Directory.Exists(Path.Combine(dir.FullName, "tools")))
            {
                return dir.FullName;
            }
            dir = dir.Parent;
        }
    }

    return Path.GetFullPath(Environment.CurrentDirectory);
}

static int Fail(string message)
{
    Console.Error.WriteLine(message);
    PrintUsage();
    return 1;
}

static string SanitizeFileName(string value)
{
    var invalid = Path.GetInvalidFileNameChars().ToHashSet();
    var chars = value.Select(c => invalid.Contains(c) ? '_' : c).ToArray();
    string result = new(chars);
    return string.IsNullOrWhiteSpace(result) ? "model" : result;
}

sealed class StepResult
{
    public string Project { get; set; } = "";
    public int ExitCode { get; set; }
    public string StandardOutput { get; set; } = "";
    public string StandardError { get; set; } = "";
}

static class SemanticBridgeBuilder
{
    public static BridgeArtifact WriteArtifacts(
        string assemblyPath,
        string outputDir,
        List<SectionViewReport> sectionViews,
        JsonElement structuralEvidenceRoot,
        string structuralEvidencePath,
        UserDiagnosticContext userContext)
    {
        if (sectionViews.Count == 0)
            throw new InvalidOperationException("At least one section view is required.");

        var primary = sectionViews[0];
        var sketch = primary.Report.SketchGraph;
        var bounds = Bounds.FromEdges(sketch.Edges);
        var edges = sketch.Edges.Select(e => BuildEdgeNode(e, bounds)).ToList();
        var patches = BuildPatches(sketch, bounds, 6, 4);
        var audit = Audit(sectionViews, structuralEvidenceRoot);
        var localFeatures = BuildLocalFeatureCandidates(sectionViews, structuralEvidenceRoot);
        var sectionViewFacts = sectionViews.Select(v => BuildSectionViewFacts(v)).ToList();
        var semanticFusionEvidence = BuildSemanticFusionEvidence(sectionViews);
        var sectionPolicy = BuildSectionPolicy();
        var processPrinciples = BuildProcessSemanticInferencePrinciples();
        var graphContract = BuildMechanicalSemanticGraphContract();
        var retrievalTargets = BuildKnowledgeRetrievalTargets();
        var structure = new
        {
            Schema = "brep_semantic_bridge_v1",
            Source = new
            {
                AssemblyPath = assemblyPath,
                PrimarySectionReportPath = primary.ReportPath,
                SectionReportPaths = sectionViews.ToDictionary(v => v.Id, v => v.ReportPath, StringComparer.OrdinalIgnoreCase),
                StructuralEvidencePath = structuralEvidencePath,
                SketchSchema = sketch.Schema,
                SketchSource = sketch.Source
            },
            UserContext = new
            {
                product_description = userContext.ProductDescription,
                purchased_component_hints = userContext.PurchasedComponentHints,
                part_function_profile = userContext.PartFunctionProfile,
                usage_policy = "User-supplied product description is functional context for LLM interpretation. User-supplied purchased component hints are exact complete component or file names; matching components are assembly_context_only and not part-design diagnostic targets."
            },
            Summary = new
            {
                EdgeCount = sketch.Edges.Count,
                RegionCount = sketch.Regions.Count,
                RelationCount = sketch.Relations.Count,
                PathCount = sketch.Paths.Count,
                LoopCount = sketch.Loops.Count,
                Units = sketch.Source.Units,
                Bounds = bounds.ToDto(),
                Owners = sketch.Edges.Select(e => e.Owner).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList(),
                Roles = sketch.Edges.Select(e => e.Role).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList(),
                RelationCounts = sketch.Relations.GroupBy(r => r.Type).ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase)
            },
            SemanticFusionEvidence = semanticFusionEvidence,
            SectionPolicy = sectionPolicy,
            SelfEvolutionAudit = audit,
            ProcessSemanticInferencePrinciples = processPrinciples,
            KnowledgeRetrievalTargets = retrievalTargets,
            LocalFeatureCandidates = localFeatures,
            SectionViews = sectionViewFacts,
            TwoDimensionalBrep = new
            {
                Edges = edges,
                Regions = sketch.Regions.Select(r => new
                {
                    r.Id,
                    r.Owner,
                    r.Role,
                    r.LoopRefs
                }),
                Paths = sketch.Paths.Select(p => new
                {
                    p.Id,
                    p.EdgeRefs
                }),
                Relations = sketch.Relations.Select(r => new
                {
                    r.Type,
                    r.A,
                    r.B
                }),
                Dimensions = sketch.Dimensions.Select(d => new
                {
                    d.Kind,
                    d.Value
                })
            },
            ThreeDimensionalFacts = ExtractStructuralFacts(structuralEvidenceRoot),
            PatchMap = patches,
            MechanicalSemanticGraphContract = graphContract,
            InterpretationContract = new
            {
                Task = "Convert B-rep-derived structural facts into a grounded mechanical semantic graph, then explain the likely mechanical structure and function.",
                GroundingRule = "Only use facts present in this JSON. Every semantic node, edge, measurement, and local subgraph must cite supporting edges, regions, patches, components, cylinder faces, or relations. Mark uncertain claims explicitly.",
                ExpectedOutput = new[]
                {
                    "mechanical_semantic_graph_v1",
                    "short_model_identity",
                    "main_components_and_roles",
                    "important_contacts_or_fits",
                    "removal_or_assembly_related_features",
                    "uncertain_or_missing_information",
                    "brep_extractable_rule_candidates",
                    "self_evolution_request_v1"
                }
            }
        };

        string structureGraphPath = Path.Combine(outputDir, "structure_graph.json");
        string patchMapPath = Path.Combine(outputDir, "patch_map.json");
        string renderSvgPath = Path.Combine(outputDir, "render.svg");
        string llmInputPath = Path.Combine(outputDir, "semantic_bridge_package.json");
        string promptPath = Path.Combine(outputDir, "prompt.md");
        string auditPath = Path.Combine(outputDir, "self_evolution_audit.json");
        string graphContractPath = Path.Combine(outputDir, "mechanical_semantic_graph_contract.json");

        File.WriteAllText(structureGraphPath, JsonSerializer.Serialize(structure, JsonOptions.Default), new UTF8Encoding(false));
        File.WriteAllText(patchMapPath, JsonSerializer.Serialize(new { Schema = "brep_patch_map_v1", Bounds = bounds.ToDto(), Patches = patches }, JsonOptions.Default), new UTF8Encoding(false));
        File.WriteAllText(renderSvgPath, RenderSvg(sketch, bounds, patches), new UTF8Encoding(false));
        File.WriteAllText(auditPath, JsonSerializer.Serialize(audit, JsonOptions.Default), new UTF8Encoding(false));
        File.WriteAllText(graphContractPath, JsonSerializer.Serialize(graphContract, JsonOptions.Default), new UTF8Encoding(false));

        var llmInput = new
        {
            Schema = "brep_llm_input_v1",
            Instruction = "You are a mechanical design expert. Convert the model into mechanical_semantic_graph_v1 using only the B-rep-derived structure graph, semantic fusion evidence, images, and patch map. Return concise Chinese explanations and cite ids.",
            RenderSvgFile = renderSvgPath,
            MechanicalSemanticGraphContractFile = graphContractPath,
            UserContext = new
            {
                product_description = userContext.ProductDescription,
                purchased_component_hints = userContext.PurchasedComponentHints,
                part_function_profile = userContext.PartFunctionProfile
            },
            ProcessSemanticInferencePrinciples = processPrinciples,
            StructureGraph = structure
        };
        File.WriteAllText(llmInputPath, JsonSerializer.Serialize(llmInput, JsonOptions.Default), new UTF8Encoding(false));

        string prompt = BuildPrompt(assemblyPath, structure, renderSvgPath, userContext);
        File.WriteAllText(promptPath, prompt, new UTF8Encoding(false));

        return new BridgeArtifact
        {
            StructureGraphPath = structureGraphPath,
            PatchMapPath = patchMapPath,
            RenderSvgPath = renderSvgPath,
            LlmInputPath = llmInputPath,
            PromptPath = promptPath,
            MechanicalSemanticGraphContractPath = graphContractPath,
            SelfEvolutionAuditPath = auditPath,
            Audit = audit,
            PromptText = prompt
        };
    }

    static Dictionary<string, object?> BuildProcessSemanticInferencePrinciples()
    {
        return new Dictionary<string, object?>
        {
            ["schema"] = "process_semantic_inference_principles_v1",
            ["purpose"] = "Provide conservative, B-rep-grounded process-role assumptions before matching design-taboo knowledge chunks.",
            ["machined_vs_unmachined_surface"] = new Dictionary<string, object?>
            {
                ["core_principle"] = "Treat machining-surface semantics as evidence-grounded candidates only. Surfaces involved in contact, locating, fit, sealing, support, or relative motion may be machined-surface candidates; unrelated surrounding outer surfaces or allowance areas may be unmachined-surface candidates.",
                ["partial_contact_rule"] = "When only a local patch on a larger face has functional contact, mark only that contact patch as a machined-surface candidate and keep the surrounding non-contact region separate.",
                ["high_confidence_evidence"] = new[]
                {
                    "assembly_contact_or_clearance_relation",
                    "mating_face_pair",
                    "coincident_or_near_coplanar_contact_patch",
                    "bolt_or_pin_connection_nearby",
                    "seal_or_bearing_or_cover_interface",
                    "face_area_ratio_between_contact_patch_and_surrounding_surface"
                },
                ["confidence_policy"] = "High confidence requires assembly contact, PMI, roughness, or process annotation. Geometry-only proximity is medium or low confidence."
            },
            ["casting_part_inference"] = new Dictionary<string, object?>
            {
                ["core_principle"] = "Treat casting semantics as a candidate only when part complexity, manufacturability, local machining need, and shell/rib/boss/cavity evidence support it.",
                ["typical_part_names"] = new[] { "housing", "case", "cover", "base", "bracket", "shell", "support" },
                ["high_confidence_evidence"] = new[]
                {
                    "material_or_process_label",
                    "bom_or_component_name",
                    "thin_wall_or_shell_structure",
                    "large_irregular_outer_surface",
                    "ribs_or_stiffeners",
                    "bosses_around_holes",
                    "local_machined_pads_on_rough_body",
                    "complex_internal_cavity"
                },
                ["negative_defaults"] = new[] { "shaft", "sleeve", "gear", "bearing", "spacer", "ring", "fastener" },
                ["confidence_policy"] = "High confidence requires material, BOM/name, or process annotation. Geometry-only shell complexity is medium confidence. Shafts, sleeves, gears, rings, and fasteners are not casting candidates unless explicit evidence exists."
            }
        };
    }

    static Dictionary<string, object?> BuildKnowledgeRetrievalTargets()
    {
        return new Dictionary<string, object?>
        {
            ["schema"] = "mechanical_semantic_retrieval_targets_v1",
            ["purpose"] = "Split retrieval guidance: part/feature units retrieve the part-design knowledge library, while interface/functional_group units retrieve the assembly/function-group knowledge library. These are not rule ids and must not be copied into retrieval_sentence.",
            ["targets"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["primary_view"] = "part_local_shape",
                    ["semantic_focus"] = "local bosses, steps, flanges, holes, ribs, wall thickness transitions, rounded or sharp transitions, and local material allowance features",
                    ["must_find_roles"] = new[]
                    {
                        "host_part_or_component",
                        "local_feature",
                        "adjacent_boundary_or_transition",
                        "functional_surface_if_any"
                    },
                    ["must_measure_or_estimate"] = new[]
                    {
                        "diameter_or_width_or_height_when_available",
                        "local_margin_or_step_height_when_available",
                        "relative_size_relation_when available",
                        "uncertain_or_missing_measurements"
                    },
                    ["brep_evidence"] = new[]
                    {
                        "FeatureGraph feature ids",
                        "ComponentEvidencePackage component ownership",
                        "section-view local boundaries",
                        "assembly face/contact refs when available",
                        "standard and section images"
                    }
                },
                new Dictionary<string, object?>
                {
                    ["primary_view"] = "surface_role_boundary",
                    ["semantic_focus"] = "machined and unmachined surface boundaries, contact pads, mating surfaces, locating surfaces, register surfaces, and surrounding rough or nonfunctional surfaces",
                    ["role_definition"] = "In the current project convention, a machined face is first inferred from a face or local patch that contacts or mates with another part; non-contact surrounding body surface is only an unmachined candidate.",
                    ["must_find_roles"] = new[]
                    {
                        "host_body",
                        "local_contact_face_as_machined_face_candidate",
                        "surrounding_unmachined_surface_candidate",
                        "raised_boss_or_step"
                    },
                    ["must_measure_or_estimate"] = new[]
                    {
                        "assembly_contact_or_mate_face_pair",
                        "contact_patch_area_or_edge_span",
                        "surrounding_surface_area_or_span",
                        "step_height_between_local_face_and_surrounding_surface",
                        "coplanar_or_near_coplanar_status"
                    },
                    ["brep_evidence"] = new[]
                    {
                        "contact relations as primary machined-face evidence",
                        "mate/contact face ownership",
                        "coplanar or near-coplanar face/edge relations",
                        "adjacent outer boundary edges",
                        "local feature candidate subgraphs",
                        "section patch ownership and roles"
                    }
                },
                new Dictionary<string, object?>
                {
                    ["primary_view"] = "assembly_contact_fit",
                    ["semantic_focus"] = "contacts, fits, coaxial relations, locating relations, fastening interfaces, bearing or shaft interfaces, and assembly clearances",
                    ["must_find_roles"] = new[]
                    {
                        "component_a",
                        "component_b",
                        "contact_or_fit_surface",
                        "locating_or_supporting_role"
                    },
                    ["brep_evidence"] = new[]
                    {
                        "ComponentEvidencePackage contact ids",
                        "AssemblyFaceContacts",
                        "StructuralFaultProbe contact facts",
                        "section-view contact or adjacency relations",
                        "visible image evidence"
                    }
                },
                new Dictionary<string, object?>
                {
                    ["primary_view"] = "disassembly_maintenance_access",
                    ["semantic_focus"] = "assembly and maintenance accessibility, removal paths, blocked fasteners or covers, tool clearance, external openings, and service sequence constraints",
                    ["must_find_roles"] = new[]
                    {
                        "target_part_or_fastener",
                        "access_direction",
                        "blocking_part_or_surface",
                        "available_gap_or_opening"
                    },
                    ["brep_evidence"] = new[]
                    {
                        "outside-adjacent section edges",
                        "standard views",
                        "section images",
                        "component ownership",
                        "assembly contact or enclosure facts"
                    }
                },
                new Dictionary<string, object?>
                {
                    ["primary_view"] = "load_strength_stiffness",
                    ["semantic_focus"] = "thin walls, ribs, supports, stress concentration candidates, unsupported overhangs, abrupt thickness changes, and stiffness paths",
                    ["must_find_roles"] = new[]
                    {
                        "load_bearing_body",
                        "rib_or_support_feature",
                        "weak_transition_or_thin_wall_candidate",
                        "adjacent_constraint_or_force_path_if_known"
                    },
                    ["brep_evidence"] = new[]
                    {
                        "FeatureGraph grouped features",
                        "section geometry",
                        "part images",
                        "component category and review scope"
                    }
                }
            }
        };
    }

    static Dictionary<string, object?> BuildSemanticFusionEvidence(List<SectionViewReport> sectionViews)
    {
        var primary = sectionViews[0];
        return new Dictionary<string, object?>
        {
            ["schema"] = "mechanical_semantic_fusion_evidence_bridge_v1",
            ["purpose"] = "Carry the Agent4 semantic fusion contract into the AI semantic step. This is evidence and schema guidance only; it is not final diagnosis and does not contain retrieved rule ids.",
            ["primary_report_path"] = primary.ReportPath,
            ["primary"] = ReadSectionSemanticFusionEvidence(primary.ReportPath, includeFullEvidence: true),
            ["section_views"] = sectionViews.Select(v => new Dictionary<string, object?>
            {
                ["id"] = v.Id,
                ["guide_axis"] = v.GuideAxis,
                ["report_path"] = v.ReportPath,
                ["evidence"] = ReadSectionSemanticFusionEvidence(v.ReportPath, includeFullEvidence: false)
            }).ToList()
        };
    }

    static Dictionary<string, object?> ReadSectionSemanticFusionEvidence(string reportPath, bool includeFullEvidence)
    {
        var evidence = new Dictionary<string, object?>
        {
            ["report_path"] = reportPath,
            ["evidence_detail"] = includeFullEvidence ? "full_primary_brep_semantic_evidence" : "secondary_view_summary_only"
        };

        if (!File.Exists(reportPath))
        {
            evidence["missing"] = true;
            return evidence;
        }

        using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
        var root = doc.RootElement;
        if (includeFullEvidence)
        {
            AddJsonPropertyIfPresent(evidence, root, "DocumentKind");
            AddJsonPropertyIfPresent(evidence, root, "Path");
            AddJsonPropertyIfPresent(evidence, root, "FeatureGraph");
            AddJsonPropertyIfPresent(evidence, root, "SemanticFusionContract");
            AddJsonPropertyIfPresent(evidence, root, "AssemblyComponents");
            AddJsonPropertyIfPresent(evidence, root, "AssemblyFaces");
            AddJsonPropertyIfPresent(evidence, root, "ComponentEvidencePackages");
            AddJsonPropertyIfPresent(evidence, root, "PartImagePlan");
            AddJsonPropertyIfPresent(evidence, root, "ComponentContextImagePlan");
            AddJsonPropertyIfPresent(evidence, root, "AssemblyFaceContacts");
            AddJsonPropertyIfPresent(evidence, root, "AssemblyMates");
        }
        AddJsonPropertyIfPresent(evidence, root, "ImageExport");
        AddJsonPropertyIfPresent(evidence, root, "SectionImagePlan");

        evidence["summary"] = new Dictionary<string, object?>
        {
            ["feature_count"] = CountArray(root, "FeatureGraph", "Features"),
            ["feature_relation_count"] = CountArray(root, "FeatureGraph", "Relations"),
            ["component_count"] = CountArray(root, "AssemblyComponents"),
            ["component_evidence_package_count"] = CountArray(root, "ComponentEvidencePackages"),
            ["part_image_plan_count"] = CountArray(root, "PartImagePlan"),
            ["assembly_face_contact_count"] = CountArray(root, "AssemblyFaceContacts")
        };
        return evidence;
    }

    static void AddJsonPropertyIfPresent(Dictionary<string, object?> target, JsonElement root, string propertyName)
    {
        if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(propertyName, out var value))
            target[propertyName] = value.Clone();
    }

    static int CountArray(JsonElement root, string propertyName)
    {
        return root.ValueKind == JsonValueKind.Object &&
               root.TryGetProperty(propertyName, out var value) &&
               value.ValueKind == JsonValueKind.Array
            ? value.GetArrayLength()
            : 0;
    }

    static int CountArray(JsonElement root, string objectPropertyName, string arrayPropertyName)
    {
        return root.ValueKind == JsonValueKind.Object &&
               root.TryGetProperty(objectPropertyName, out var obj) &&
               obj.ValueKind == JsonValueKind.Object &&
               obj.TryGetProperty(arrayPropertyName, out var value) &&
               value.ValueKind == JsonValueKind.Array
            ? value.GetArrayLength()
            : 0;
    }

    static Dictionary<string, object?> BuildMechanicalSemanticGraphContract()
    {
        return new Dictionary<string, object?>
        {
            ["schema"] = "mechanical_semantic_graph_v1",
            ["model_identity"] = new Dictionary<string, object?>
            {
                ["name"] = "string",
                ["source_assembly_path"] = "string",
                ["likely_equipment_type"] = "string",
                ["confidence"] = 0.0,
                ["evidence"] = new[] { "component/edge/region/relation ids" }
            },
            ["nodes"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["id"] = "node_id",
                    ["type"] = "part|feature|face_region|interface|process_role|functional_role|missing_or_uncertain",
                    ["semantic_type"] = "cast_housing|end_cover|machined_register|local_machined_face|surrounding_unmachined_surface|raised_boss|tool_gap|ejector_port|unknown",
                    ["name"] = "human readable Chinese name",
                    ["source_refs"] = new[] { "component names, edge ids, region ids, patch ids, face ids" },
                    ["confidence"] = 0.0,
                    ["evidence"] = "short explanation grounded in B-rep facts"
                }
            },
            ["edges"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["id"] = "edge_id",
                    ["type"] = "contacts|fits_with|coaxial_with|contains|adjacent_to|outside_accessible|supports|locates|is_machined_candidate_of|is_cast_candidate_of|has_boundary_step|missing_required_relation",
                    ["from"] = "node_id",
                    ["to"] = "node_id",
                    ["source_refs"] = new[] { "relation ids or edge/face refs" },
                    ["confidence"] = 0.0,
                    ["evidence"] = "short explanation grounded in B-rep facts"
                }
            },
            ["measurements"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["id"] = "measurement_id",
                    ["type"] = "radius|diameter|axial_span|radial_margin|gap_width|step_height|area_ratio|unknown_threshold",
                    ["value"] = "number or null",
                    ["units"] = "mm|model_units|unitless|unknown",
                    ["source_refs"] = new[] { "dimension ids, cylinder faces, edges, patches" },
                    ["confidence"] = 0.0
                }
            },
            ["local_subgraphs"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["id"] = "subgraph_id",
                    ["pattern_type"] = "casting_boss_register_interface|machined_unmachined_surface_boundary|sleeve_removal_access|bearing_support|other",
                    ["primary_view"] = "assembly_contact_fit|disassembly_maintenance_access|drawing_annotation_requirement|feature_pattern|load_strength_stiffness|locating_limit_datum|motion_interference_dof|operation_safety_environment|part_local_shape|surface_role_boundary|system_function_condition|transmission_standard_parameters",
                    ["secondary_views"] = new[] { "optional diagnostic view ids" },
                    ["retrieval_sentence"] = "neutral Chinese fact sentence for knowledge retrieval; no rule id and no fault judgement words",
                    ["inferred_functions"] = new[] { "assembly, locating, support, sealing, machining, maintenance, or motion functions inferred from evidence" },
                    ["inferred_attributes"] = new object[]
                    {
                        new Dictionary<string, object?>
                        {
                            ["name"] = "machined_surface_candidate|unmachined_surface_candidate|locating_face_candidate|contact_face_candidate|tool_access_limited|other",
                            ["value"] = true,
                            ["confidence"] = 0.0,
                            ["reason"] = "short evidence-grounded reason; no final fault judgement",
                            ["source_image_names"] = new[] { "exact image names used for this inference" },
                            ["source_brep_refs"] = new[] { "face/feature/component refs used for this inference" },
                            ["source_relation_refs"] = new[] { "contact/mate/relation refs used for this inference" }
                        }
                    },
                    ["retrieval_fact_tags"] = new[] { "stable model-side fact tags aligned with knowledge required_model_facts; no rule ids" },
                    ["retrieval_relations"] = new[] { "human-readable local relations used for retrieval" },
                    ["image_evidence_names"] = new[] { "exact image names or image plan ids that support this local subgraph" },
                    ["image_observations"] = new object[]
                    {
                        new Dictionary<string, object?>
                        {
                            ["image_name"] = "component_contexts/component_13/isometric",
                            ["observation"] = "what this image shows",
                            ["supports"] = new[] { "role or inferred attribute name" },
                            ["confidence"] = 0.0
                        }
                    },
                    ["node_ids"] = new[] { "node_id" },
                    ["edge_ids"] = new[] { "edge_id" },
                    ["measurement_ids"] = new[] { "measurement_id" },
                    ["roles"] = new[] { "semantic roles grounded by this subgraph" },
                    ["retrieval_keywords"] = new[] { "Chinese and English keywords for knowledge-base retrieval" },
                    ["confidence"] = 0.0,
                    ["evidence"] = new[] { "edge/region/patch/component/relation ids" },
                    ["reason"] = "why this subgraph is suitable for matching a knowledge chunk"
                }
            },
            ["local_semantic_units"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["local_id"] = "unit_id",
                    ["linked_subgraph_id"] = "subgraph_id",
                    ["pattern_type"] = "casting_boss_register_interface|machined_unmachined_surface_boundary|sleeve_removal_access|bearing_support|other",
                    ["primary_view"] = "same taxonomy id used by the knowledge index",
                    ["secondary_views"] = new[] { "optional diagnostic view ids" },
                    ["retrieval_sentence"] = "one neutral Chinese fact sentence. This is the primary retrieval unit.",
                    ["retrieval_chain"] = new Dictionary<string, object?>
                    {
                        ["geometric_fact_for_retrieval"] = "human-readable geometric fact generalized for retrieval; no coordinates or raw ids",
                        ["mechanical_role"] = "mechanical role inferred from the geometry, such as internal machined surface, locating boss, contact pad, maintenance opening",
                        ["process_assembly_maintenance_semantics"] = "manufacturing, assembly, disassembly, maintenance, strength, locating, or motion semantics used for retrieval"
                    },
                    ["retrieval_semantic_roles"] = new[] { "retrieval-only semantic role labels; no raw B-rep ids" },
                    ["inferred_functions"] = new[] { "component or local feature functions inferred for retrieval" },
                    ["inferred_attributes"] = new object[]
                    {
                        new Dictionary<string, object?>
                        {
                            ["name"] = "machined_surface_candidate|unmachined_surface_candidate|locating_face_candidate|contact_face_candidate|tool_access_limited|other",
                            ["value"] = true,
                            ["confidence"] = 0.0,
                            ["reason"] = "short evidence-grounded reason; no final fault judgement",
                            ["source_image_names"] = new[] { "exact image names used for this inference" },
                            ["source_brep_refs"] = new[] { "face/feature/component refs used for this inference" },
                            ["source_relation_refs"] = new[] { "contact/mate/relation refs used for this inference" }
                        }
                    },
                    ["retrieval_fact_tags"] = new[] { "through_hole_candidate, entry_face_inclined, small_local_contact_face, surrounding_large_surface, etc." },
                    ["retrieval_relations"] = new[] { "hole enters inclined surface, local contact face is near-coplanar with surrounding surface, etc." },
                    ["image_evidence_names"] = new[] { "exact image names or image plan ids that support this local semantic unit" },
                    ["image_observations"] = new object[]
                    {
                        new Dictionary<string, object?>
                        {
                            ["image_name"] = "component_highlights/component_13/isometric",
                            ["observation"] = "what this image supports",
                            ["supports"] = new[] { "retrieval role, function, or inferred attribute name" },
                            ["confidence"] = 0.0
                        }
                    },
                    ["normalized_measurements"] = new object[]
                    {
                        new Dictionary<string, object?>
                        {
                            ["name"] = "axis_to_face_angle|step_height|diameter|clearance|area_ratio|other",
                            ["value"] = null,
                            ["unit"] = "mm|deg|unitless|unknown",
                            ["confidence"] = 0.0
                        }
                    },
                    ["fact_bundle"] = new Dictionary<string, object?>
                    {
                        ["geometry_description_for_judgement"] = "concrete geometry description used only after retrieval for verification",
                        ["components"] = new[] { "component names or node ids" },
                        ["roles"] = new[] { "local semantic roles" },
                        ["measurements"] = new[] { "measurement ids and values if available" },
                        ["evidence_refs"] = new[] { "edge/region/patch/component/relation ids" },
                        ["uncertainties"] = new[] { "missing facts that affect final judgement" }
                    },
                    ["dedup_key"] = "stable key for this local unit"
                }
            },
            ["semantic_coverage_audit"] = new Dictionary<string, object?>
            {
                ["purpose"] = "machine-checkable audit proving local semantic units were split by coverage, not collapsed into a few generic descriptions",
                ["candidate_anchor_types"] = new[] { "part", "feature", "interface", "functional_group" },
                ["minimum_rules"] = new[]
                {
                    "Create at least one part-level local unit for every nonstandard component instance whose review_scope is part_design_required.",
                    "Create feature-level local units for supported holes, hole groups, slots, bosses, ribs, steps, shafts, sleeves, pads, machining candidates, locating/contact faces, and tool-access candidates.",
                    "Create interface-level local units for supported contacts, fits, coaxial relations, mate relations, support/locating relations, motion pairs, and clearance/accessibility relations.",
                    "Create functional-group local units for each geometry-derived internal component group or connected mechanism group when evidence supports group-level judgement.",
                    "Do not merge separate physical locations just because they share the same feature type; do merge repeated equivalent features into one group when they share owner, role, dimensions, and relation signature."
                },
                ["covered_part_component_ids"] = new[] { "component ids with at least one local semantic unit" },
                ["covered_feature_anchor_ids"] = new[] { "feature ids or face-group ids covered by feature-level units" },
                ["covered_interface_relation_ids"] = new[] { "contact/mate/relation ids covered by interface-level units" },
                ["covered_functional_group_ids"] = new[] { "internal/component group ids covered by group-level units" },
                ["uncovered_candidate_anchors"] = new object[]
                {
                    new Dictionary<string, object?>
                    {
                        ["anchor_type"] = "part|feature|interface|functional_group",
                        ["anchor_id"] = "id from B-rep evidence",
                        ["reason_not_covered"] = "standard/context-only component, duplicate equivalent anchor, insufficient evidence, or intentionally excluded"
                    }
                },
                ["local_unit_count_by_anchor_type"] = new Dictionary<string, object?>
                {
                    ["part"] = 0,
                    ["feature"] = 0,
                    ["interface"] = 0,
                    ["functional_group"] = 0
                }
            },
            ["uncertain_roles"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["role"] = "string",
                    ["candidate_node_ids"] = new[] { "node_id" },
                    ["why_uncertain"] = "string",
                    ["facts_needed"] = new[] { "specific B-rep or metadata facts" }
                }
            },
            ["missing_facts"] = new[] { "specific missing facts that prevent reliable diagnosis" },
            ["evidence_index"] = new object[]
            {
                new Dictionary<string, object?>
                {
                    ["ref"] = "edge/region/patch/component/face/relation id",
                    ["kind"] = "edge|region|patch|component|face|relation|dimension",
                    ["summary"] = "what this evidence supports"
                }
            }
        };
    }

    static object BuildEdgeNode(SketchDrawableEdge edge, Bounds bounds)
    {
        double[] start = Point2(edge.Start);
        double[] end = Point2(edge.End);
        return new
        {
            edge.Id,
            edge.SourceEdgeIds,
            edge.StartVertex,
            edge.EndVertex,
            Start = start,
            End = end,
            NormalizedStart = bounds.Normalize(start),
            NormalizedEnd = bounds.Normalize(end),
            edge.Owner,
            edge.Role,
            Length = Math.Round(Distance(start, end), 4),
            Orientation = Orientation(start, end),
            BBox = Bounds.FromPoints([start, end]).ToDto(),
            Attributes = edge.Attributes
        };
    }

    public static SelfEvolutionAudit Audit(List<SectionViewReport> sectionViews, JsonElement structuralEvidenceRoot)
    {
        var reasons = new List<string>();
        int totalEdges = sectionViews.Sum(v => v.Report.SketchGraph.Edges.Count);
        int totalContacts = sectionViews.Sum(v => v.Report.SketchGraph.Relations.Count(r => r.Type == "contact"));
        int totalOutside = sectionViews.Sum(v => v.Report.SketchGraph.Relations.Count(r => r.Type == "adjacent_to_outside"));
        var assemblies = structuralEvidenceRoot.ValueKind == JsonValueKind.Array
            ? structuralEvidenceRoot.EnumerateArray().ToList()
            : [];
        int componentCount = assemblies.Sum(a => GetInt(a, "ComponentCount"));
        int cylinderFaceCount = assemblies
            .SelectMany(a => GetArray(a, "Components"))
            .SelectMany(c => GetArray(c, "CylinderFaces"))
            .Count();

        if (sectionViews.Count < 2)
            reasons.Add("Only one section view is available; non-axisymmetric features, ports, slots, and local access gaps may be missed.");
        if (totalContacts == 0)
            reasons.Add("No contact relations were extracted; fit/contact semantics are weak.");
        if (totalOutside == 0)
            reasons.Add("No outside-adjacent edges were extracted; external accessibility cannot be evaluated.");
        if (componentCount == 0)
            reasons.Add("No 3D component facts were extracted.");
        if (cylinderFaceCount == 0)
            reasons.Add("No cylindrical faces were extracted; sleeve/hole/shaft semantics are weak.");
        if (totalEdges > 80 && sectionViews.Count < 3)
            reasons.Add("The section graph is complex; additional section views are recommended.");

        var supported = new List<string>();
        if (sectionViews.Count < 3)
            supported.Add("run_additional_section_views");
        supported.Add("summarize_local_candidate_subgraphs");
        supported.Add("compare_component_outer_radii");
        supported.Add("check_outside_adjacency");

        return new SelfEvolutionAudit
        {
            NeedsMoreFacts = reasons.Count > 0,
            Confidence = Math.Round(Math.Max(0.25, 1.0 - reasons.Count * 0.14), 2),
            Reasons = reasons,
            SupportedNextActions = supported,
            CurrentFactCoverage = new Dictionary<string, object?>
            {
                ["section_view_count"] = sectionViews.Count,
                ["edge_count_total"] = totalEdges,
                ["contact_relation_count_total"] = totalContacts,
                ["outside_adjacency_count_total"] = totalOutside,
                ["component_count"] = componentCount,
                ["cylinder_face_count"] = cylinderFaceCount
            }
        };
    }

    static object BuildSectionViewFacts(SectionViewReport view)
    {
        var sketch = view.Report.SketchGraph;
        var bounds = Bounds.FromEdges(sketch.Edges);
        return new
        {
            view.Id,
            view.GuideAxis,
            view.ReportPath,
            SelectionPrinciple = "single_center_or_symmetry_section",
            AxisGroupId = view.Report.AxisGroupId,
            SectionPlane = new
            {
                view.Report.SectionPlane.OriginMm,
                view.Report.SectionPlane.AxisMm,
                view.Report.SectionPlane.PlaneNormalMm,
                view.Report.SectionPlane.UAxisMm,
                view.Report.SectionPlane.VAxisMm,
                Meaning = "The section should pass through the model centerline or symmetry plane when available; otherwise use the middle position of the model."
            },
            EdgeCount = sketch.Edges.Count,
            RegionCount = sketch.Regions.Count,
            RelationCounts = sketch.Relations.GroupBy(r => r.Type).ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase),
            Bounds = bounds.ToDto(),
            Owners = sketch.Edges.Select(e => e.Owner).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList(),
            OuterBoundaryEdges = sketch.Edges
                .Where(e => e.Role == "outer_boundary_candidate")
                .Select(e => new
                {
                    e.Id,
                    e.Owner,
                    e.Role,
                    e.SourceEdgeIds,
                    Start = Point2(e.Start),
                    End = Point2(e.End),
                    Orientation = Orientation(Point2(e.Start), Point2(e.End)),
                    OutsideAdjacent = IsEdgeOutsideAdjacent(sketch, e)
                })
        };
    }

    static Dictionary<string, object?> BuildSectionPolicy()
    {
        return new Dictionary<string, object?>
        {
            ["schema"] = "mechanical_section_policy_disabled_v1",
            ["default_section_count"] = 0,
            ["principle"] = "Section B-rep and section images are disabled. Use assembly/component B-rep, standard images, component highlight images, part-file images, internal component group context images, or computed B-rep checks.",
            ["avoid"] = new[]
            {
                "Do not request section images.",
                "Do not cite section_view_id or section planes.",
                "Do not use section evidence in fact_bundle."
            },
            ["required_fact_bundle_fields_when_section_used"] = Array.Empty<string>()
        };
    }

    static List<object> BuildLocalFeatureCandidates(List<SectionViewReport> views, JsonElement structuralEvidenceRoot)
    {
        var candidates = new List<object>();
        var componentRadiusFacts = ExtractComponentRadiusFacts(structuralEvidenceRoot);

        foreach (var view in views)
        {
            var sketch = view.Report.SketchGraph;
            foreach (var ownerGroup in sketch.Edges.GroupBy(e => e.Owner).Where(g => !string.IsNullOrWhiteSpace(g.Key)))
            {
                var outsideEdges = ownerGroup.Where(e => IsEdgeOutsideAdjacent(sketch, e)).ToList();
                var contactEdges = ownerGroup.Where(e => EdgeHasContact(sketch, e)).ToList();
                var outerEdges = outsideEdges.Where(e => e.Role == "outer_boundary_candidate").ToList();
                if (outsideEdges.Count > 0 || contactEdges.Count > 0)
                {
                    candidates.Add(new
                    {
                        Kind = "component_local_access_and_fit_subgraph",
                        ViewId = view.Id,
                        Owner = ownerGroup.Key,
                        ComponentCategory = MostCommonAttribute(ownerGroup, "component_category"),
                        OutsideAdjacentEdgeIds = outsideEdges.Select(e => e.Id).ToList(),
                        ContactEdgeIds = contactEdges.Select(e => e.Id).ToList(),
                        OuterBoundaryEdgeIds = outerEdges.Select(e => e.Id).ToList(),
                        EvidenceStrength = Math.Round(Math.Min(1.0, outsideEdges.Count * 0.08 + contactEdges.Count * 0.06 + outerEdges.Count * 0.08), 2)
                    });
                }
            }
        }

        foreach (var pair in componentRadiusFacts.GroupBy(f => f.ComponentName))
        {
            var maxRadius = pair.Where(f => f.RadiusMm.HasValue).OrderByDescending(f => f.RadiusMm).FirstOrDefault();
            if (maxRadius != null)
            {
                candidates.Add(new
                {
                    Kind = "component_outer_radius_fact",
                    maxRadius.ComponentName,
                    maxRadius.Category,
                    maxRadius.RadiusMm,
                    FaceIndex = maxRadius.FaceIndex,
                    Note = "Largest extracted cylindrical radius for this component."
                });
            }
        }

        foreach (var a in componentRadiusFacts)
        {
            foreach (var b in componentRadiusFacts)
            {
                if (string.Equals(a.ComponentName, b.ComponentName, StringComparison.OrdinalIgnoreCase) ||
                    !a.RadiusMm.HasValue ||
                    !b.RadiusMm.HasValue)
                    continue;
                if (IsSleeveText(a.ComponentName) || a.Category is "spacer_or_ring" or "sleeve")
                {
                    double delta = a.RadiusMm.Value - b.RadiusMm.Value;
                    if (delta > 0.5)
                    {
                        candidates.Add(new
                        {
                            Kind = "exposed_shoulder_radius_contrast_candidate",
                            SleeveComponent = a.ComponentName,
                            ReferenceComponent = b.ComponentName,
                            SleeveRadiusMm = a.RadiusMm,
                            ReferenceRadiusMm = b.RadiusMm,
                            DeltaMm = Math.Round(delta, 4),
                            Confidence = delta >= 3.0 ? 0.82 : 0.58,
                            Note = "A sleeve/ring cylindrical radius is larger than another component radius, which may indicate an exposed shoulder if outside-adjacent edges support it."
                        });
                    }
                }
            }
        }

        return candidates;
    }

    static List<ComponentRadiusFact> ExtractComponentRadiusFacts(JsonElement root)
    {
        var facts = new List<ComponentRadiusFact>();
        var assemblies = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToList() : [];
        foreach (var report in assemblies)
        {
            foreach (var component in GetArray(report, "Components"))
            {
                string componentName = GetString(component, "ComponentName");
                string category = GetString(component, "Category");
                foreach (var face in GetArray(component, "CylinderFaces"))
                {
                    facts.Add(new ComponentRadiusFact
                    {
                        ComponentName = componentName,
                        Category = category,
                        FaceIndex = GetInt(face, "FaceIndex"),
                        RadiusMm = GetNullableDouble(face, "RadiusMm")
                    });
                }
            }
        }
        return facts;
    }

    static bool IsEdgeOutsideAdjacent(Canonical2DSketchGraph sketch, SketchDrawableEdge edge)
    {
        return sketch.Relations.Any(r =>
            r.Type == "adjacent_to_outside" &&
            (SameRef(r.A, edge.Id) || edge.SourceEdgeIds.Any(id => SameRef(r.A, id))));
    }

    static bool EdgeHasContact(Canonical2DSketchGraph sketch, SketchDrawableEdge edge)
    {
        return sketch.Relations.Any(r =>
            r.Type == "contact" &&
            (SameRef(r.A, edge.Id) || SameRef(r.B, edge.Id) ||
             edge.SourceEdgeIds.Any(id => SameRef(r.A, id) || SameRef(r.B, id))));
    }

    static string MostCommonAttribute(IEnumerable<SketchDrawableEdge> edges, string key)
    {
        return edges.Select(e => Attribute(e, key))
            .Where(s => !string.IsNullOrWhiteSpace(s))
            .GroupBy(s => s, StringComparer.OrdinalIgnoreCase)
            .OrderByDescending(g => g.Count())
            .Select(g => g.Key)
            .FirstOrDefault() ?? "";
    }

    static List<PatchNode> BuildPatches(Canonical2DSketchGraph sketch, Bounds bounds, int columns, int rows)
    {
        var patches = new List<PatchNode>();
        if (!bounds.Valid)
            return patches;

        double width = bounds.Width / columns;
        double height = bounds.Height / rows;
        for (int row = 0; row < rows; row++)
        {
            for (int col = 0; col < columns; col++)
            {
                var patchBounds = new Bounds
                {
                    MinX = bounds.MinX + col * width,
                    MaxX = col == columns - 1 ? bounds.MaxX : bounds.MinX + (col + 1) * width,
                    MinY = bounds.MinY + row * height,
                    MaxY = row == rows - 1 ? bounds.MaxY : bounds.MinY + (row + 1) * height
                };

                var edgeIds = sketch.Edges
                    .Where(edge => EdgeTouchesPatch(edge, patchBounds))
                    .Select(edge => edge.Id)
                    .ToList();

                patches.Add(new PatchNode
                {
                    Id = $"patch_r{row}_c{col}",
                    Row = row,
                    Column = col,
                    Bounds = patchBounds.ToDto(),
                    NormalizedBounds = new
                    {
                        Min = bounds.Normalize([patchBounds.MinX, patchBounds.MinY]),
                        Max = bounds.Normalize([patchBounds.MaxX, patchBounds.MaxY])
                    },
                    EdgeIds = edgeIds,
                    Owners = edgeIds.Select(id => sketch.Edges.First(e => e.Id == id).Owner).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList(),
                    Roles = edgeIds.Select(id => sketch.Edges.First(e => e.Id == id).Role).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList()
                });
            }
        }

        return patches;
    }

    static bool EdgeTouchesPatch(SketchDrawableEdge edge, Bounds patch)
    {
        var edgeBounds = Bounds.FromPoints([Point2(edge.Start), Point2(edge.End)]);
        return edgeBounds.Valid &&
               edgeBounds.MaxX >= patch.MinX &&
               edgeBounds.MinX <= patch.MaxX &&
               edgeBounds.MaxY >= patch.MinY &&
               edgeBounds.MinY <= patch.MaxY;
    }

    static object ExtractStructuralFacts(JsonElement root)
    {
        var assemblies = root.ValueKind == JsonValueKind.Array
            ? root.EnumerateArray().ToList()
            : [];

        return new
        {
            Assemblies = assemblies.Select(report => new
            {
                Ok = GetBool(report, "Ok"),
                AssemblyName = GetString(report, "AssemblyName"),
                AssemblyPath = GetString(report, "AssemblyPath"),
                Message = GetString(report, "Message"),
                ComponentCount = GetInt(report, "ComponentCount"),
                BearingCount = GetInt(report, "BearingCount"),
                Components = GetArray(report, "Components").Select(c => new
                {
                    ComponentName = GetString(c, "ComponentName"),
                    PartName = GetString(c, "PartName"),
                    Category = GetString(c, "Category"),
                    FaceCount = GetInt(c, "FaceCount"),
                    CylinderFaces = GetArray(c, "CylinderFaces").Take(12).Select(f => new
                    {
                        FaceIndex = GetInt(f, "FaceIndex"),
                        RadiusMm = GetNullableDouble(f, "RadiusMm"),
                        AxialMinMm = GetNullableDouble(f, "AxialMinMm"),
                        AxialMaxMm = GetNullableDouble(f, "AxialMaxMm"),
                        Axis = GetDoubleArray(f, "Axis"),
                        AxisPointMm = GetDoubleArray(f, "AxisPointMm")
                    })
                }),
                ComponentFixations = GetArray(report, "ComponentFixations").Select(f => new
                {
                    ComponentName = GetString(f, "ComponentName"),
                    Category = GetString(f, "Category"),
                    Status = GetString(f, "Status"),
                    LeftFixed = GetBool(f, "LeftFixed"),
                    RightFixed = GetBool(f, "RightFixed"),
                    FixedBothSides = GetBool(f, "FixedBothSides"),
                    InnerRadiusMm = GetNullableDouble(f, "InnerRadiusMm"),
                    OuterRadiusMm = GetNullableDouble(f, "OuterRadiusMm")
                }),
                Bearings = GetArray(report, "Bearings").Select(b => new
                {
                    ComponentName = GetString(b, "ComponentName"),
                    Status = GetString(b, "Status"),
                    Message = GetString(b, "Message"),
                    InnerRadiusMm = GetNullableDouble(b, "InnerRadiusMm"),
                    OuterRadiusMm = GetNullableDouble(b, "OuterRadiusMm"),
                    AxialMinMm = GetNullableDouble(b, "AxialMinMm"),
                    AxialMaxMm = GetNullableDouble(b, "AxialMaxMm")
                })
            })
        };
    }

    static string RenderSvg(Canonical2DSketchGraph sketch, Bounds bounds, List<PatchNode> patches)
    {
        const int width = 1400;
        const int height = 900;
        const int margin = 60;
        double scale = bounds.Valid ? Math.Min((width - margin * 2) / Math.Max(bounds.Width, 0.001), (height - margin * 2) / Math.Max(bounds.Height, 0.001)) : 1.0;

        string P(double[] point)
        {
            double x = margin + (point[0] - bounds.MinX) * scale;
            double y = height - margin - (point[1] - bounds.MinY) * scale;
            return $"{x.ToString("0.###", CultureInfo.InvariantCulture)},{y.ToString("0.###", CultureInfo.InvariantCulture)}";
        }

        var sb = new StringBuilder();
        sb.AppendLine($"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">""");
        sb.AppendLine("""<rect width="100%" height="100%" fill="#f8fafc"/>""");
        sb.AppendLine("""<g stroke="#cbd5e1" stroke-width="1" fill="none">""");
        foreach (var patch in patches)
        {
            var min = ((JsonElement)JsonSerializer.SerializeToElement(patch.Bounds)).GetProperty("Min").EnumerateArray().Select(x => x.GetDouble()).ToArray();
            var max = ((JsonElement)JsonSerializer.SerializeToElement(patch.Bounds)).GetProperty("Max").EnumerateArray().Select(x => x.GetDouble()).ToArray();
            double x1 = margin + (min[0] - bounds.MinX) * scale;
            double y1 = height - margin - (max[1] - bounds.MinY) * scale;
            double w = (max[0] - min[0]) * scale;
            double h = (max[1] - min[1]) * scale;
            sb.AppendLine($"""<rect x="{x1.ToString("0.###", CultureInfo.InvariantCulture)}" y="{y1.ToString("0.###", CultureInfo.InvariantCulture)}" width="{w.ToString("0.###", CultureInfo.InvariantCulture)}" height="{h.ToString("0.###", CultureInfo.InvariantCulture)}"/>""");
        }
        sb.AppendLine("</g>");

        foreach (var edge in sketch.Edges)
        {
            string color = ColorFor(edge.Owner, edge.Role);
            string strokeWidth = edge.Role.Contains("contact", StringComparison.OrdinalIgnoreCase) ? "5" : "3";
            sb.AppendLine($"""<line x1="{P(Point2(edge.Start)).Split(',')[0]}" y1="{P(Point2(edge.Start)).Split(',')[1]}" x2="{P(Point2(edge.End)).Split(',')[0]}" y2="{P(Point2(edge.End)).Split(',')[1]}" stroke="{color}" stroke-width="{strokeWidth}" stroke-linecap="round">""");
            sb.AppendLine($"""<title>{EscapeXml(edge.Id)} owner={EscapeXml(edge.Owner)} role={EscapeXml(edge.Role)}</title>""");
            sb.AppendLine("</line>");
        }

        sb.AppendLine("""<g font-family="Segoe UI, Arial, sans-serif" font-size="14" fill="#334155">""");
        sb.AppendLine($"""<text x="24" y="32">B-rep semantic bridge render: {EscapeXml(sketch.Source.Kind)} units={EscapeXml(sketch.Source.Units)}</text>""");
        sb.AppendLine($"""<text x="24" y="54">edges={sketch.Edges.Count}, regions={sketch.Regions.Count}, relations={sketch.Relations.Count}</text>""");
        sb.AppendLine("</g>");
        sb.AppendLine("</svg>");
        return sb.ToString();
    }

    static string BuildPrompt(string assemblyPath, object structure, string renderSvgPath, UserDiagnosticContext userContext)
    {
        string json = JsonSerializer.Serialize(structure, JsonOptions.Default);
        if (json.Length > 120000)
            json = json[..120000] + "\n...TRUNCATED_FOR_PROMPT; full JSON is in structure_graph.json...";

        var sb = new StringBuilder();
        sb.AppendLine("You are a mechanical design expert.");
        sb.AppendLine("The following JSON is a B-rep-derived structural bridge representation extracted from a SolidWorks model. It is not manual labeling.");
        sb.AppendLine();
        sb.AppendLine($"Model path: {assemblyPath}");
        sb.AppendLine($"2D render SVG: {renderSvgPath}");
        sb.AppendLine();
        sb.AppendLine("User-supplied product context:");
        sb.AppendLine(string.IsNullOrWhiteSpace(userContext.ProductDescription)
            ? "- product_description: not provided"
            : $"- product_description: {userContext.ProductDescription}");
        sb.AppendLine(userContext.PurchasedComponentHints.Count == 0
            ? "- purchased_component_hints: none"
            : $"- purchased_component_hints: {string.Join("; ", userContext.PurchasedComponentHints)}");
        sb.AppendLine("- Use product_description to infer component functions and assembly roles when B-rep/images/mates alone are ambiguous.");
        sb.AppendLine("- Treat purchased_component_hints as exact complete component/file names supplied by the user: matching components are context only, not nonstandard part-design diagnostic targets.");
        sb.AppendLine();
        sb.AppendLine("Please answer in Chinese and use only facts present in the JSON:");
        sb.AppendLine("1. First convert the B-rep facts into a standard mechanical semantic graph.");
        sb.AppendLine("2. Explain what this assembly/part structure most likely is.");
        sb.AppendLine("3. Explain main components, mechanical roles, contacts, fits, accessibility, and process-role candidates.");
        sb.AppendLine("4. Identify local semantic subgraphs that can retrieve the appropriate design-taboo knowledge library: part/feature anchors use the part-design library, and interface/functional_group anchors use the assembly/function-group library. Cover all supported local semantics; do not limit yourself to a preselected rule.");
        sb.AppendLine("5. Cite which edges, regions, patches, cylindrical faces, components, or contact relations support each conclusion.");
        sb.AppendLine("6. State what remains uncertain or missing.");
        sb.AppendLine("7. If the information is insufficient, provide a self-evolution request.");
        sb.AppendLine();
        sb.AppendLine("Rules:");
        sb.AppendLine("- Cite edge/region/patch/component/relation ids or component names for every important conclusion.");
        sb.AppendLine("- Do not invent facts outside the JSON.");
        sb.AppendLine("- Section evidence is disabled in this workflow. Do not request or cite section_view_id, section planes, or section images.");
        sb.AppendLine("- Treat SemanticFusionEvidence.SemanticFusionContract as the governing schema and process contract for FeatureGraph, global semantic graph, local_semantic_units, fact_bundle, dedup, and retrieval.");
        sb.AppendLine("- Use FeatureGraph as the grouped B-rep evidence layer, then refine or verify it with attached SolidWorks standard images, component highlight images, part-file images, and internal component group context images. Images may support interpretation but must not replace B-rep evidence.");
        sb.AppendLine("- Keep ComponentEvidencePackages as hard ownership boundaries. Do not mix faces, features, contacts, or images from different component instances inside one part-design fact_bundle unless the unit is explicitly an assembly relation.");
        sb.AppendLine("- For components whose review_scope is assembly_context_only, do not generate nonstandard part-structure taboo units. Use them only as assembly/contact/fit/accessibility context or standard-part selection context.");
        sb.AppendLine("- For components whose review_scope is part_design_required, generate local units from that component's ComponentEvidencePackage plus the matching PartImagePlan images.");
        sb.AppendLine("- Treat each ComponentEvidencePackage as one component instance in the assembly. If the same part file appears in multiple positions, keep separate component-instance local units and cite the matching assembly component highlight images.");
        sb.AppendLine("- Use component part-file images for external shape and manufacturability. Use assembly component highlight images to understand where this exact component instance sits and what function it serves in the assembly.");
        sb.AppendLine("- For internal components, use reusable assembly component context/isolate group images. These images are generated from geometry-driven internal component groups: six-axis BBox occlusion analysis plus contact-graph expansion, before AI function understanding.");
        sb.AppendLine("- Whenever an image contributes to a mechanical semantic statement, inferred function, inferred attribute, local_semantic_unit, or local_subgraph, record the exact image evidence name in source_image_names or image_evidence_names. Use names from PartImagePlan, assembly component highlight images, or ComponentContextImagePlan; do not use a generic global isometric image when a component-specific image exists.");
        sb.AppendLine("- Every inferred_attributes item must include source_image_names and source_brep_refs/source_relation_refs when available, so later judgement can reload the exact image and B-rep/mate evidence used for the inference.");
        sb.AppendLine("- Treat standard or purchased components as context only: they may be occlusion blockers or recorded boundary components, but they are not nonstandard part-design diagnostic targets.");
        sb.AppendLine("- If a process role such as cast part, machined face, or unmachined surface is inferred rather than directly labeled, mark confidence and cite the process semantic inference principle used.");
        sb.AppendLine("- Missing semantic labels are not enough reason to leave a fact unresolved. For every useful mechanical attribute or function, first classify the missing fact as direct_observed, computable_from_brep, inferable_from_assembly, inferable_from_image, mechanical_common_sense_inference, drawing_or_process_document_only, or unsupported.");
        sb.AppendLine("- For inferable facts, generate an evidence-grounded AI inference instead of putting the fact only in missing_facts. This applies to machining/precision surface candidates, blank-forming candidates, locating/support/sealing/guiding/load-bearing roles, maintenance access, motion/interference, safety, lubrication/drainage/venting, standard-part context, and process-economy semantics.");
        sb.AppendLine("- When B-rep, mates, contacts, and images do not directly state an attribute, use mechanical_common_sense_inference before marking it unsupported: reason what kind of part or structure should normally have this function/process/property, then decide whether the current component should likely have it.");
        sb.AppendLine("- Use mechanical design common sense for ai_inferred facts: first reason what kind of geometry normally needs the attribute/function/process, then compare the current component geometry, contacts, mates, assembly position, images, and engineering purpose. Record confidence and source_image_names/source_brep_refs/source_relation_refs.");
        sb.AppendLine("- Mark a fact as drawing_or_process_document_only only when it requires exact 2D drawing/BOM/process text such as roughness value, tolerance grade, GD&T, heat treatment, material grade, or technical requirement wording. Do not block structural/function inference just because these exact annotations are absent.");
        sb.AppendLine("- Follow the mechanical diagnostic retrieval contract: produce local_semantic_units for knowledge retrieval, and keep local_subgraphs as their graph evidence.");
        sb.AppendLine("- Local semantic splitting is coverage-driven, not summary-driven. A local_semantic_unit is the smallest model-side evidence package that can independently retrieve and verify a design taboo rule.");
        sb.AppendLine("- Generate local units by four anchor types: part, feature, interface, and functional_group.");
        sb.AppendLine("- Part anchors: every nonstandard component instance with review_scope=part_design_required must have at least one part-level local_semantic_unit, even when several instances share the same part file.");
        sb.AppendLine("- Feature anchors: generate separate units for supported holes/hole groups, slots, bosses, ribs, steps, shafts, sleeves, pads, thin walls, machining candidates, locating/contact faces, and tool-access candidates. Group only repeated equivalent features with the same owner, role, dimensions, and relation signature.");
        sb.AppendLine("- Interface anchors: generate separate units for supported contacts, fits, coaxial relations, mate relations, locating/support relations, motion pairs, and clearance/accessibility relations. Do not merge different physical interfaces just because their wording is similar.");
        sb.AppendLine("- Functional group anchors: generate units for each geometry-derived internal component group or connected mechanism group when the group-level evidence is needed to judge function, accessibility, motion, support, or maintenance.");
        sb.AppendLine("- Do not collapse the whole assembly into only a few generic patterns. If there are many part_design_required component instances, the number of local_semantic_units should normally be at least the nonstandard component count before adding feature/interface/group units.");
        sb.AppendLine("- If two candidate units may retrieve different knowledge categories, or the same rule could be true for one location and false for another, keep them separate. Merge only duplicate equivalent anchors that share primary_view, component/anchor id, core relation signature, dimensions, and image/B-rep evidence.");
        sb.AppendLine("- Add semantic_coverage_audit to the graph. It must list covered_part_component_ids, covered_feature_anchor_ids, covered_interface_relation_ids, covered_functional_group_ids, uncovered_candidate_anchors, and local_unit_count_by_anchor_type.");
        sb.AppendLine("- semantic_coverage_audit is an audit of actual output, not a substitute for local units. local_unit_count_by_anchor_type must equal the actual number of local_semantic_units for each anchor_type.");
        sb.AppendLine("- If semantic_coverage_audit lists a part/feature/interface/group as covered, at least one actual local_semantic_unit must cite that anchor in anchor_type + anchor_id or fact_bundle.anchor. Do not claim coverage without an actual unit.");
        sb.AppendLine("- Do not summarize many unrelated parts or features into one local unit. One local unit may cover a repeated equivalent feature group only when owner, role, dimensions, relation signature, and image/B-rep evidence are the same.");
        sb.AppendLine("- Every local_semantic_units item must include local_id, pattern_type, primary_view, secondary_views, retrieval_sentence, retrieval_chain, retrieval_semantic_roles, fact_bundle, and dedup_key.");
        sb.AppendLine("- Every local_semantic_units item must include anchor_type and anchor_id. anchor_type must be one of: part, feature, interface, functional_group.");
        sb.AppendLine("- Every local_semantic_units item should also include inferred_functions, inferred_attributes, retrieval_fact_tags, retrieval_relations, and normalized_measurements when supported by B-rep/image evidence. These fields are retrieval aids, not final fault judgements.");
        sb.AppendLine("- Every local_semantic_units item and every local_subgraphs item must include image_evidence_names and image_observations. Use exact names from PartImagePlan, assembly component highlight images, or ComponentContextImagePlan. Do not leave image_evidence_names empty when any component-specific image exists for the anchor.");
        sb.AppendLine("- Split retrieval information from judgement evidence. Retrieval uses only retrieval_sentence, retrieval_chain, retrieval_semantic_roles, primary_view, secondary_views, and pattern_type.");
        sb.AppendLine("- retrieval_chain must follow this structure: geometric_fact_for_retrieval -> mechanical_role -> process_assembly_maintenance_semantics.");
        sb.AppendLine("- geometric_fact_for_retrieval is a generalized human-readable geometric fact for retrieval, not a coordinate list and not raw B-rep ids.");
        sb.AppendLine("- fact_bundle stores concrete geometry descriptions, measurements, evidence refs, section data, and image evidence for later verification after knowledge retrieval. It must not be needed for retrieval.");
        sb.AppendLine("- Measurement wording must preserve units and meaning: RadiusMm/radius_mm is radius, not diameter. If a hole diameter is needed, use DiameterMm/diameter_mm or compute 2 * radius.");
        sb.AppendLine("- Inferred attributes such as machined_surface_candidate and unmachined_surface_candidate are allowed only with confidence and evidence-grounded reasons. Keep observed facts, inferred attributes, and missing facts separate.");
        sb.AppendLine("- local_semantic_units.retrieval_sentence is the primary retrieval unit. It must be one neutral Chinese fact sentence grounded by B-rep/image evidence.");
        sb.AppendLine("- local_semantic_units.retrieval_sentence must not contain raw B-rep ids such as sketch_edge_1, edge_12, face_3, patch_r0_c1, region_outside, or measurement_1. Use human-readable mechanical semantics such as 澶栧渾鏌遍潰銆佺閮ㄥ彴闃躲€佸眬閮ㄥ嚫鍙般€佸姞宸ラ潰銆侀潪鍔犲伐闈€侀厤鍚堥潰銆佸瓟鍙ｃ€佸３浣撳琛ㄩ潰.");
        sb.AppendLine("- local_semantic_units.primary_view must use the same taxonomy ids as the knowledge index: assembly_contact_fit, disassembly_maintenance_access, drawing_annotation_requirement, feature_pattern, load_strength_stiffness, locating_limit_datum, motion_interference_dof, operation_safety_environment, part_local_shape, surface_role_boundary, system_function_condition, transmission_standard_parameters.");
        sb.AppendLine("- local_semantic_units.fact_bundle must contain concrete B-rep/image facts, including component_id/display_name/review_scope, semantic_location, evidence_refs, image_evidence_names, roles, measurements when available, and uncertainties.");
        sb.AppendLine("- Deduplicate local_semantic_units according to SemanticFusionContract.DedupPolicy. Do not output multiple units for the same primary_view + component/anchor + core relation signature.");
        sb.AppendLine("- Do not include possible rule ids, knowledge ids, or chapter item numbers in local_semantic_units or local_subgraphs. Rule ids are added only after backend knowledge retrieval.");
        sb.AppendLine("- Do not include rule numbers, violation claims, or judgement words such as error, violation, should, fault, wrong, 閿欒, 杩濆弽, 搴旇, 涓嶅簲 in retrieval_sentence.");
        sb.AppendLine("- Every local_subgraphs item must include id, pattern_type, primary_view, retrieval_sentence, roles, evidence, confidence, and reason.");
        sb.AppendLine("- local_subgraphs.primary_view must be one of: assembly_contact_fit, disassembly_maintenance_access, drawing_annotation_requirement, feature_pattern, load_strength_stiffness, locating_limit_datum, motion_interference_dof, operation_safety_environment, part_local_shape, surface_role_boundary, system_function_condition, transmission_standard_parameters.");
        sb.AppendLine("- local_subgraphs.retrieval_sentence must be a neutral Chinese fact sentence suitable for knowledge-base retrieval. Do not include rule numbers, violation claims, or words such as 閿欒, 杩濆弽, 搴旇.");
        sb.AppendLine("- The first JSON code block must have root property \"schema\": \"mechanical_semantic_graph_v1\" and follow the MechanicalSemanticGraphContract embedded in the JSON.");
        sb.AppendLine("- The final JSON code block must have root property \"schema\": \"self_evolution_request_v1\" and exactly this shape:");
        sb.AppendLine();
        sb.AppendLine("First required JSON block:");
        sb.AppendLine("```json");
        sb.AppendLine("{");
        sb.AppendLine("  \"schema\": \"mechanical_semantic_graph_v1\",");
        sb.AppendLine("  \"model_identity\": {},");
        sb.AppendLine("  \"nodes\": [],");
        sb.AppendLine("  \"edges\": [],");
        sb.AppendLine("  \"measurements\": [],");
        sb.AppendLine("  \"semantic_coverage_audit\": {");
        sb.AppendLine("    \"covered_part_component_ids\": [],");
        sb.AppendLine("    \"covered_feature_anchor_ids\": [],");
        sb.AppendLine("    \"covered_interface_relation_ids\": [],");
        sb.AppendLine("    \"covered_functional_group_ids\": [],");
        sb.AppendLine("    \"uncovered_candidate_anchors\": [],");
        sb.AppendLine("    \"local_unit_count_by_anchor_type\": { \"part\": 0, \"feature\": 0, \"interface\": 0, \"functional_group\": 0 }");
        sb.AppendLine("  },");
        sb.AppendLine("  \"local_semantic_units\": [");
        sb.AppendLine("    {");
        sb.AppendLine("      \"local_id\": \"unit_id\",");
        sb.AppendLine("      \"linked_subgraph_id\": \"subgraph_id\",");
        sb.AppendLine("      \"pattern_type\": \"casting_boss_register_interface\",");
        sb.AppendLine("      \"primary_view\": \"surface_role_boundary\",");
        sb.AppendLine("      \"secondary_views\": [],");
        sb.AppendLine("      \"retrieval_sentence\": \"neutral Chinese fact sentence for retrieval, without rule id or judgement conclusion\",");
        sb.AppendLine("      \"retrieval_chain\": { \"geometric_fact_for_retrieval\": \"鐢ㄤ簬妫€绱㈢殑姒傛嫭鍑犱綍浜嬪疄\", \"mechanical_role\": \"鏈烘瑙掕壊\", \"process_assembly_maintenance_semantics\": \"宸ヨ壓/瑁呴厤/缁存姢璇箟\" },");
        sb.AppendLine("      \"retrieval_semantic_roles\": [],");
        sb.AppendLine("      \"inferred_functions\": [],");
        sb.AppendLine("      \"inferred_attributes\": [{ \"name\": \"machined_surface_candidate\", \"value\": true, \"confidence\": 0.0, \"reason\": \"evidence-grounded inference without violation judgement\" }],");
        sb.AppendLine("      \"retrieval_fact_tags\": [],");
        sb.AppendLine("      \"retrieval_relations\": [],");
        sb.AppendLine("      \"normalized_measurements\": [],");
        sb.AppendLine("      \"image_evidence_names\": [],");
        sb.AppendLine("      \"image_observations\": [{ \"image_name\": \"component_contexts/component_13/isometric\", \"observation\": \"what this image supports\", \"supports\": [\"semantic role or inferred attribute name\"], \"confidence\": 0.0 }],");
        sb.AppendLine("      \"fact_bundle\": { \"geometry_description_for_judgement\": \"concrete geometry description for later judgement\", \"semantic_location\": \"\", \"evidence_refs\": [], \"image_evidence_names\": [], \"image_observations\": [], \"roles\": [], \"measurements\": [], \"uncertainties\": [] },");
        sb.AppendLine("      \"dedup_key\": \"stable_key\"");
        sb.AppendLine("    }");
        sb.AppendLine("  ],");
        sb.AppendLine("  \"local_subgraphs\": [],");
        sb.AppendLine("  \"uncertain_roles\": [],");
        sb.AppendLine("  \"missing_facts\": [],");
        sb.AppendLine("  \"evidence_index\": []");
        sb.AppendLine("}");
        sb.AppendLine("```");
        sb.AppendLine();
        sb.AppendLine("Final required JSON block:");
        sb.AppendLine("```json");
        sb.AppendLine("{");
        sb.AppendLine("  \"schema\": \"self_evolution_request_v1\",");
        sb.AppendLine("  \"confidence\": 0.0,");
        sb.AppendLine("  \"needs_more_facts\": true,");
        sb.AppendLine("  \"missing_facts\": [\"facts that should be extracted next\"],");
        sb.AppendLine("  \"supported_next_actions\": [\"run_additional_section_views\", \"summarize_local_candidate_subgraphs\", \"compare_component_outer_radii\", \"check_outside_adjacency\"],");
        sb.AppendLine("  \"reason\": \"why more facts are or are not needed\"");
        sb.AppendLine("}");
        sb.AppendLine("```");
        sb.AppendLine();
        sb.AppendLine("Structure JSON:");
        sb.AppendLine("```json");
        sb.AppendLine(json);
        sb.AppendLine("```");
        return sb.ToString();
    }

    static string ColorFor(string owner, string role)
    {
        string text = $"{owner} {role}".ToLowerInvariant();
        if (text.Contains("sleeve") || text.Contains("\u5957") || text.Contains("bushing")) return "#2563eb";
        if (text.Contains("bearing") || text.Contains("\u8f74\u627f")) return "#dc2626";
        if (text.Contains("shaft") || text.Contains("\u8f74")) return "#059669";
        if (text.Contains("housing") || text.Contains("\u7bb1")) return "#7c3aed";
        if (text.Contains("gap") || text.Contains("void") || text.Contains("port")) return "#ea580c";
        return "#0f172a";
    }

    static string Orientation(double[] a, double[] b)
    {
        double dx = Math.Abs(a[0] - b[0]);
        double dy = Math.Abs(a[1] - b[1]);
        if (dx <= 0.15 && dy > 0.15) return "vertical";
        if (dy <= 0.15 && dx > 0.15) return "horizontal";
        return "diagonal_or_curve_chord";
    }

    static double[] Point2(double[] point) => point.Length >= 2 ? [point[0], point[1]] : [0.0, 0.0];

    static bool SameRef(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);

    static bool IsSleeveText(string value)
    {
        return value.Contains("\u5957", StringComparison.OrdinalIgnoreCase) ||
               value.Contains("sleeve", StringComparison.OrdinalIgnoreCase) ||
               value.Contains("bushing", StringComparison.OrdinalIgnoreCase) ||
               value.Contains("spacer", StringComparison.OrdinalIgnoreCase) ||
               value.Contains("ring", StringComparison.OrdinalIgnoreCase);
    }

    static string Attribute(SketchDrawableEdge edge, string key)
    {
        if (!edge.Attributes.TryGetValue(key, out var value) || value == null)
            return "";
        return value switch
        {
            string s => s,
            JsonElement e when e.ValueKind == JsonValueKind.String => e.GetString() ?? "",
            _ => value.ToString() ?? ""
        };
    }

    static double Distance(double[] a, double[] b)
    {
        double dx = a[0] - b[0];
        double dy = a[1] - b[1];
        return Math.Sqrt(dx * dx + dy * dy);
    }

    static string EscapeXml(string value)
    {
        return value
            .Replace("&", "&amp;", StringComparison.Ordinal)
            .Replace("<", "&lt;", StringComparison.Ordinal)
            .Replace(">", "&gt;", StringComparison.Ordinal)
            .Replace("\"", "&quot;", StringComparison.Ordinal);
    }

    static string GetString(JsonElement element, string name)
    {
        return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? "" : "";
    }

    static bool GetBool(JsonElement element, string name)
    {
        return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var p) && p.ValueKind is JsonValueKind.True or JsonValueKind.False && p.GetBoolean();
    }

    static int GetInt(JsonElement element, string name)
    {
        return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out int value) ? value : 0;
    }

    static double? GetNullableDouble(JsonElement element, string name)
    {
        return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetDouble(out double value) ? Math.Round(value, 4) : null;
    }

    static double[] GetDoubleArray(JsonElement element, string name)
    {
        return element.ValueKind == JsonValueKind.Object &&
               element.TryGetProperty(name, out var p) &&
               p.ValueKind == JsonValueKind.Array
            ? p.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Number).Select(x => Math.Round(x.GetDouble(), 4)).ToArray()
            : [];
    }

    static List<JsonElement> GetArray(JsonElement element, string name)
    {
        return element.ValueKind == JsonValueKind.Object &&
               element.TryGetProperty(name, out var p) &&
               p.ValueKind == JsonValueKind.Array
            ? p.EnumerateArray().ToList()
            : [];
    }
}

static class OpenAiInterpreter
{
    public static int FinalizeInterfacesExisting(string root, string[] args)
    {
        if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
            return WriteError("finalize-interfaces-existing requires an existing semantic bridge output directory.");

        string outputDir = Path.GetFullPath(args[0].Trim().Trim('"'));
        if (!Directory.Exists(outputDir))
            return WriteError($"output directory not found: {outputDir}");

        string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "qwen-vl-max";
        string apiMode = NormalizeApiMode(Environment.GetEnvironmentVariable("OPENAI_API_MODE") ?? "chat_completions");
        string baseUrl = NormalizeBaseUrl(Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1");
        int maxImages = ReadInt(Environment.GetEnvironmentVariable("OPENAI_MAX_IMAGES"), 6, 0, 30);
        string stage = "all";

        for (int i = 1; i < args.Length; i++)
        {
            var arg = args[i];
            if (arg.Equals("--model", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
            {
                model = args[++i];
            }
            else if (arg.StartsWith("--model=", StringComparison.OrdinalIgnoreCase))
            {
                model = arg["--model=".Length..];
            }
            else if (arg.Equals("--max-images", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
            {
                maxImages = ReadInt(args[++i], maxImages, 0, 30);
            }
            else if (arg.StartsWith("--max-images=", StringComparison.OrdinalIgnoreCase))
            {
                maxImages = ReadInt(arg["--max-images=".Length..], maxImages, 0, 30);
            }
            else if (arg.Equals("--stage", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
            {
                stage = NormalizeInterfaceFinalizeStage(args[++i]);
            }
            else if (arg.StartsWith("--stage=", StringComparison.OrdinalIgnoreCase))
            {
                stage = NormalizeInterfaceFinalizeStage(arg["--stage=".Length..]);
            }
            else
            {
                return WriteError($"Unknown finalize-interfaces-existing option: {arg}");
            }
        }
        if (string.IsNullOrWhiteSpace(stage))
            return WriteError("Unknown finalize-interfaces-existing stage. Supported stages: all, context, anchor, group, export.");

        string structureGraphPath = Path.Combine(outputDir, "structure_graph.json");
        if (!File.Exists(structureGraphPath))
            return WriteError($"structure_graph.json not found: {structureGraphPath}");

        var plans = BuildComponentPromptPlans(structureGraphPath, outputDir);
        if (plans.Count == 0)
            return WriteError($"No component prompt plans could be built from {structureGraphPath}");

        var diagnosticContext = BuildDiagnosticContextArtifacts(structureGraphPath, outputDir, plans);
        var groupRelationshipArtifacts = WriteFunctionalGroupRelationshipArtifacts(outputDir, plans);
        diagnosticContext.FunctionalGroupRelationshipsPath = groupRelationshipArtifacts.JsonPath;
        diagnosticContext.FunctionalGroupRelationshipsMarkdownPath = groupRelationshipArtifacts.MarkdownPath;
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        diagnosticContext.InterfaceAnchorAnalysisPath = Path.Combine(contextDir, "interface_anchor_analysis.json");
        diagnosticContext.FunctionalGroupDescriptionsPath = Path.Combine(contextDir, "functional_group_descriptions.json");
        diagnosticContext.FunctionalGroupDescriptionsMarkdownPath = Path.Combine(contextDir, "functional_group_descriptions.md");
        diagnosticContext.InterfaceFunctionDescriptionsPath = Path.Combine(contextDir, "interface_function_descriptions.json");
        diagnosticContext.InterfaceFunctionDescriptionsMarkdownPath = Path.Combine(contextDir, "interface_function_descriptions.md");

        var runAnchor = stage is "all" or "anchor";
        var runGroup = stage is "all" or "group";
        var runExport = stage is "all" or "export";
        var needsAi = runAnchor || runGroup;
        string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "";
        if (needsAi && string.IsNullOrWhiteSpace(apiKey))
            return WriteError("OPENAI_API_KEY is not set. The selected stage requires AI/VLM analysis.");

        string proxy = FirstNonEmpty(
            Environment.GetEnvironmentVariable("HTTPS_PROXY"),
            Environment.GetEnvironmentVariable("HTTP_PROXY"),
            Environment.GetEnvironmentVariable("ALL_PROXY"));

        try
        {
            var completedStages = new List<string> { "context" };

            if (needsAi)
            {
                using var handler = new HttpClientHandler();
                if (!string.IsNullOrWhiteSpace(proxy))
                {
                    handler.UseProxy = true;
                    handler.Proxy = new WebProxy(proxy);
                }

                using var http = new HttpClient(handler)
                {
                    Timeout = TimeSpan.FromSeconds(ReadInt(Environment.GetEnvironmentVariable("OPENAI_TIMEOUT_SECONDS"), 600, 30, 1800))
                };
                http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);

                if (runAnchor)
                {
                    GenerateInterfaceAnchorAnalysisAsync(
                        model,
                        outputDir,
                        plans,
                        diagnosticContext,
                        http,
                        baseUrl,
                        apiMode,
                        maxImages).GetAwaiter().GetResult();
                    completedStages.Add("anchor");
                }

                if (runGroup)
                {
                    GenerateFunctionalGroupDescriptionsAsync(
                        model,
                        outputDir,
                        plans,
                        diagnosticContext,
                        http,
                        baseUrl,
                        apiMode,
                        maxImages).GetAwaiter().GetResult();
                    completedStages.Add("group");
                }
            }

            if (runExport)
            {
                ExportInterfaceFunctionDescriptions(outputDir, plans, diagnosticContext);
                completedStages.Add("export");
            }

            string summaryPath = Path.Combine(outputDir, "interface_finalize_existing_summary.json");
            File.WriteAllText(summaryPath, JsonSerializer.Serialize(new
            {
                schema = "interface_finalize_existing_summary_v1",
                requested_stage = stage,
                completed_stages = completedStages,
                output_dir = outputDir,
                structure_graph_path = structureGraphPath,
                component_count = plans.Count,
                interface_anchor_analysis_path = diagnosticContext.InterfaceAnchorAnalysisPath,
                functional_group_descriptions_path = diagnosticContext.FunctionalGroupDescriptionsPath,
                interface_function_descriptions_path = diagnosticContext.InterfaceFunctionDescriptionsPath,
                interface_function_descriptions_markdown_path = diagnosticContext.InterfaceFunctionDescriptionsMarkdownPath
            }, JsonOptions.Default), new UTF8Encoding(false));

            Console.WriteLine(summaryPath);
            if (runExport)
                Console.WriteLine(diagnosticContext.InterfaceFunctionDescriptionsPath);
            return 0;
        }
        catch (Exception ex)
        {
            return WriteError($"finalize-interfaces-existing failed: {ex.Message}");
        }
    }

    static string NormalizeInterfaceFinalizeStage(string value)
    {
        var stage = (value ?? "").Trim().ToLowerInvariant().Replace("_", "-");
        return stage switch
        {
            "all" or "full" or "interfaces" or "interface" => "all",
            "context" or "prepare" or "prep" => "context",
            "anchor" or "anchors" or "interface-anchor" or "interface-anchors" => "anchor",
            "group" or "groups" or "functional-group" or "functional-groups" or "description" or "descriptions" => "group",
            "export" or "merge" or "interface-functions" or "interface-function-descriptions" => "export",
            _ => ""
        };
    }

    public static int AggregateExisting(string root, string[] args)
    {
        string outputDir = args.Length > 0
            ? Path.GetFullPath(args[0].Trim().Trim('"'))
            : Path.Combine(root, "runtime", "brep_semantic_bridge");
        if (!Directory.Exists(outputDir))
            return WriteError($"output directory not found: {outputDir}");

        var requestedComponentIds = args
            .Skip(1)
            .Where(arg => !string.IsNullOrWhiteSpace(arg))
            .SelectMany(arg => arg.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
            .ToHashSet(StringComparer.OrdinalIgnoreCase);

        string structureGraphPath = Path.Combine(outputDir, "structure_graph.json");
        if (!File.Exists(structureGraphPath))
            return WriteError($"structure_graph.json not found: {structureGraphPath}");

        string aiDir = Path.Combine(outputDir, "per_component_ai");
        if (!Directory.Exists(aiDir))
            return WriteError($"per_component_ai directory not found: {aiDir}");

        var plans = BuildComponentPromptPlans(structureGraphPath, outputDir);
        if (requestedComponentIds.Count > 0)
            plans = plans.Where(plan => requestedComponentIds.Contains(plan.ComponentId)).ToList();

        var componentResults = new List<ComponentAiResult>();
        foreach (var plan in plans)
        {
            var result = new ComponentAiResult
            {
                ComponentId = plan.ComponentId,
                InstanceName = plan.InstanceName,
                DisplayName = plan.DisplayName
            };

            var textPath = Path.Combine(aiDir, $"{SanitizeFileName(plan.ComponentId)}_interpretation.md");
            if (File.Exists(textPath))
            {
                result.ResponseText = File.ReadAllText(textPath);
                result.ResultPath = textPath;
            }
            else
            {
                var responsePath = Directory.GetFiles(aiDir, $"{SanitizeFileName(plan.ComponentId)}_iter*_response.json")
                    .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
                    .LastOrDefault();
                if (!string.IsNullOrWhiteSpace(responsePath) && File.Exists(responsePath))
                {
                    result.ResponseText = ExtractResponseText(File.ReadAllText(responsePath));
                    result.RawResponsePaths.Add(responsePath);
                }
            }

            var localJson = ExtractJsonObjectWithSchema(result.ResponseText, "mechanical_component_local_subgraph_v1")
                ?? ExtractJsonObjectWithSchema(result.ResponseText, "mechanical_semantic_graph_v1");
            localJson = NormalizeComponentLocalSubgraphJson(localJson, plan);
            var issues = ValidateComponentLocalSubgraphContract(localJson, plan);
            result.LocalSubgraphJson = localJson ?? "";
            result.Status = string.IsNullOrWhiteSpace(localJson)
                ? "missing"
                : issues.Count == 0 ? "ok" : "contract_issues";
            result.Message = string.IsNullOrWhiteSpace(localJson)
                ? "No component local subgraph JSON was found."
                : issues.Count == 0 ? "aggregated from existing AI output" : string.Join("; ", issues);
            result.Iterations.Add(new
            {
                source = "aggregate-existing",
                contract_issues = issues
            });
            componentResults.Add(result);
        }

        var aggregateJson = BuildAggregatedMechanicalSemanticGraph(structureGraphPath, plans, componentResults);
        string semanticGraphPath = Path.Combine(outputDir, "mechanical_semantic_graph.json");
        File.WriteAllText(semanticGraphPath, NormalizeJson(aggregateJson), new UTF8Encoding(false));
        var functionIntroArtifacts = WriteComponentFunctionIntroductionArtifacts(outputDir, plans, componentResults);

        string collectionPath = Path.Combine(outputDir, "local_subgraph_collection.json");
        File.WriteAllText(collectionPath, BuildLocalSubgraphCollection(plans, componentResults), new UTF8Encoding(false));

        string interpretationPath = Path.Combine(outputDir, "ai_interpretation.md");
        File.WriteAllText(interpretationPath, BuildPerComponentInterpretationMarkdown(componentResults), new UTF8Encoding(false));

        string summaryPath = Path.Combine(outputDir, "aggregate_existing_summary.json");
        File.WriteAllText(summaryPath, JsonSerializer.Serialize(new
        {
            schema = "aggregate_existing_local_subgraphs_summary_v1",
            output_dir = outputDir,
            structure_graph_path = structureGraphPath,
            component_count = plans.Count,
            requested_component_ids = requestedComponentIds,
            mechanical_semantic_graph_path = semanticGraphPath,
            component_function_introductions_json_path = functionIntroArtifacts.JsonPath,
            component_function_introductions_markdown_path = functionIntroArtifacts.MarkdownPath,
            local_subgraph_collection_path = collectionPath,
            ai_interpretation_path = interpretationPath,
            components = componentResults.Select(result => new
            {
                result.ComponentId,
                result.DisplayName,
                result.Status,
                result.Message
            })
        }, JsonOptions.Default), new UTF8Encoding(false));

        Console.WriteLine($"Aggregated {componentResults.Count} component outputs.");
        Console.WriteLine(semanticGraphPath);
        return 0;
    }

    static int WriteError(string message)
    {
        Console.Error.WriteLine(message);
        return 1;
    }

    static StepResult RunProjectWithCaptureLocal(string root, string projectRelativePath, IReadOnlyList<string> forwardedArgs)
    {
        string projectPath = Path.Combine(root, projectRelativePath);
        if (!File.Exists(projectPath))
            throw new FileNotFoundException($"Project not found: {projectPath}");

        var psi = new ProcessStartInfo("dotnet")
        {
            WorkingDirectory = root,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true
        };
        psi.ArgumentList.Add("run");
        psi.ArgumentList.Add("--project");
        psi.ArgumentList.Add(projectPath);
        psi.ArgumentList.Add("-c");
        psi.ArgumentList.Add("Release");
        psi.ArgumentList.Add("--");
        foreach (var arg in forwardedArgs)
            psi.ArgumentList.Add(arg);

        using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start project: {projectRelativePath}");
        string stdout = process.StandardOutput.ReadToEnd();
        string stderr = process.StandardError.ReadToEnd();
        process.WaitForExit();

        return new StepResult
        {
            Project = projectRelativePath,
            ExitCode = process.ExitCode,
            StandardOutput = stdout,
            StandardError = stderr
        };
    }

    static string SummarizeLocal(string text)
    {
        if (string.IsNullOrWhiteSpace(text))
            return "";
        text = text.Trim();
        return text.Length <= 1200 ? text : text[..1200] + "...";
    }

    public static async Task<AiCallResult> TryInterpret(string root, string sectionBrepExtractorProject, string model, string prompt, string outputDir, int maxIterations, string structureGraphPath = "")
    {
        string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "";
        if (string.IsNullOrWhiteSpace(apiKey))
        {
            return new AiCallResult
            {
                Status = "skipped",
                Model = model,
                Message = "OPENAI_API_KEY is not set. Generated prompt.md, semantic_bridge_package.json, and mechanical_semantic_graph_contract.json instead.",
                MechanicalSemanticGraphStatus = "not_generated_no_api_key"
            };
        }

        string baseUrl = NormalizeBaseUrl(Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1");
        string apiMode = NormalizeApiMode(Environment.GetEnvironmentVariable("OPENAI_API_MODE") ?? "chat_completions");
        int maxImages = ReadInt(Environment.GetEnvironmentVariable("OPENAI_MAX_IMAGES"), 6, 0, 30);
        int timeoutSeconds = ReadInt(Environment.GetEnvironmentVariable("OPENAI_TIMEOUT_SECONDS"), 600, 30, 1800);
        var imagePaths = apiMode == "chat_completions"
            ? CollectImagePaths(outputDir, maxImages)
            : new List<string>();
        string proxy = FirstNonEmpty(
            Environment.GetEnvironmentVariable("HTTPS_PROXY"),
            Environment.GetEnvironmentVariable("HTTP_PROXY"),
            Environment.GetEnvironmentVariable("ALL_PROXY"));

        string resultPath = Path.Combine(outputDir, "ai_interpretation.md");
        string rawPath = Path.Combine(outputDir, "ai_response.json");
        string evolutionPath = Path.Combine(outputDir, "ai_self_evolution.json");
        string semanticGraphPath = Path.Combine(outputDir, "mechanical_semantic_graph.json");
        Directory.CreateDirectory(outputDir);

        try
        {
            using var handler = new HttpClientHandler();
            if (!string.IsNullOrWhiteSpace(proxy))
            {
                handler.UseProxy = true;
                handler.Proxy = new WebProxy(proxy);
            }

            using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(timeoutSeconds) };
            http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);

            if (!string.IsNullOrWhiteSpace(structureGraphPath) && File.Exists(structureGraphPath))
            {
                var perComponent = await TryInterpretPerComponentAsync(
                    root,
                    sectionBrepExtractorProject,
                    model,
                    structureGraphPath,
                    outputDir,
                    Math.Max(1, maxIterations),
                    http,
                    baseUrl,
                    apiMode,
                    maxImages);
                if (perComponent != null)
                    return perComponent;
            }

            var iterations = new List<object>();
            string currentPrompt = prompt;
            string finalText = "";
            AiSelfEvolutionRequest? lastRequest = null;

            for (int iteration = 1; iteration <= Math.Max(1, maxIterations); iteration++)
            {
                object request = apiMode == "responses"
                    ? BuildResponsesRequest(model, currentPrompt)
                    : BuildChatCompletionsRequest(model, currentPrompt, iteration == 1 ? imagePaths : []);
                string endpoint = apiMode == "responses" ? $"{baseUrl}/responses" : $"{baseUrl}/chat/completions";

                using var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions.Default), Encoding.UTF8, "application/json");
                using var response = await http.PostAsync(endpoint, content);
                string raw = await response.Content.ReadAsStringAsync();
                string iterationRawPath = iteration == 1 ? rawPath : Path.Combine(outputDir, $"ai_response_iter{iteration}.json");
                File.WriteAllText(iterationRawPath, raw, new UTF8Encoding(false));

                if (!response.IsSuccessStatusCode)
                {
                    return new AiCallResult
                    {
                        Status = "error",
                        Model = model,
                        Message = $"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}",
                        RawResponsePath = iterationRawPath
                    };
                }

                finalText = ExtractResponseText(raw);
                lastRequest = ExtractSelfEvolutionRequest(finalText);
                var semanticGraphJson = ExtractJsonObjectWithSchema(finalText, "mechanical_semantic_graph_v1");
                var contractIssues = ValidateRetrievalContract(semanticGraphJson);
                iterations.Add(new
                {
                    Iteration = iteration,
                    ApiMode = apiMode,
                    AttachedImages = iteration == 1 ? imagePaths.Count : 0,
                    RetrievalContractIssues = contractIssues,
                    NeedsMoreFacts = lastRequest?.NeedsMoreFacts,
                    Confidence = lastRequest?.Confidence,
                    MissingFacts = lastRequest?.MissingFacts ?? [],
                    SupportedNextActions = lastRequest?.SupportedNextActions ?? [],
                    Reason = lastRequest?.Reason ?? ""
                });

                if (contractIssues.Count > 0 && iteration < maxIterations)
                {
                    currentPrompt = BuildRetrievalContractRepairPrompt(prompt, finalText, contractIssues);
                    continue;
                }

                if (lastRequest?.NeedsMoreFacts != true || iteration >= maxIterations)
                    break;

                currentPrompt = prompt + "\n\n涓婁竴杞?AI 琛ㄧず淇℃伅涓嶈冻銆傚伐鍏峰凡缁忓湪鏈湴缁撴瀯鍖呬腑鎻愪緵 SelfEvolutionAudit銆丼ectionViews 鍜?LocalFeatureCandidates銆傝鍩轰簬杩欎簺澧炲己浜嬪疄閲嶆柊瑙ｉ噴锛涘鏋滀粛涓嶇‘瀹氾紝鍙兘璇锋眰 supported_next_actions 涓垪鍑虹殑浜嬪疄绫诲瀷銆俓n";
            }

            File.WriteAllText(resultPath, finalText, new UTF8Encoding(false));
            string? mechanicalSemanticGraphJson = ExtractJsonObjectWithSchema(finalText, "mechanical_semantic_graph_v1");
            string mechanicalSemanticGraphStatus;
            if (!string.IsNullOrWhiteSpace(mechanicalSemanticGraphJson))
            {
                File.WriteAllText(semanticGraphPath, NormalizeJson(mechanicalSemanticGraphJson), new UTF8Encoding(false));
                mechanicalSemanticGraphStatus = "parsed";
            }
            else
            {
                mechanicalSemanticGraphStatus = "missing_or_unparseable";
            }

            File.WriteAllText(evolutionPath, JsonSerializer.Serialize(new
            {
                Model = model,
                MaxIterations = maxIterations,
                Iterations = iterations,
                FinalNeedsMoreFacts = lastRequest?.NeedsMoreFacts,
                FinalConfidence = lastRequest?.Confidence,
                MechanicalSemanticGraphStatus = mechanicalSemanticGraphStatus,
                MechanicalSemanticGraphPath = File.Exists(semanticGraphPath) ? semanticGraphPath : ""
            }, JsonOptions.Default), new UTF8Encoding(false));

            return new AiCallResult
            {
                Status = "ok",
                Model = model,
                Message = mechanicalSemanticGraphStatus == "parsed"
                    ? lastRequest?.NeedsMoreFacts == true
                        ? "AI interpretation and local subgraph collection generated, but the final self-evolution request still asks for more facts."
                        : "AI interpretation and local subgraph collection generated."
                    : "AI interpretation generated, but local subgraph collection was not found or could not be parsed.",
                ResultPath = resultPath,
                RawResponsePath = rawPath,
                SelfEvolutionPath = evolutionPath,
                MechanicalSemanticGraphPath = File.Exists(semanticGraphPath) ? semanticGraphPath : "",
                MechanicalSemanticGraphStatus = mechanicalSemanticGraphStatus
            };
        }
        catch (Exception ex)
        {
            return new AiCallResult
            {
                Status = "error",
                Model = model,
                Message = ex.Message,
                ResultPath = File.Exists(resultPath) ? resultPath : "",
                RawResponsePath = File.Exists(rawPath) ? rawPath : "",
                SelfEvolutionPath = File.Exists(evolutionPath) ? evolutionPath : "",
                MechanicalSemanticGraphPath = File.Exists(semanticGraphPath) ? semanticGraphPath : "",
                MechanicalSemanticGraphStatus = File.Exists(semanticGraphPath) ? "parsed_before_error" : "not_generated"
            };
        }
    }

    static int ClampComponentLocalSubgraphIterations(int requestedIterations)
    {
        if (requestedIterations < 1)
            return 1;
        return 1;
    }

    static bool ShouldRunComponentLocalSubgraphIteration(int iteration, int maxIterations) =>
        iteration <= maxIterations;

    static List<ComponentPromptPlan> BuildFeatureBatchPromptPlans(ComponentPromptPlan plan, int batchSize)
    {
        if (plan.Features.Count == 0 || plan.Features.Count <= batchSize)
        {
            plan.BatchId = $"{plan.ComponentId}_batch_01";
            plan.BatchIndex = 1;
            plan.BatchCount = 1;
            plan.BatchFeatureIds = plan.Features.Select(feature => ReadString(feature, "Id")).Where(id => !string.IsNullOrWhiteSpace(id)).ToList();
            return [plan];
        }

        var result = new List<ComponentPromptPlan>();
        var batches = plan.Features
            .Select((feature, index) => new { feature, index })
            .GroupBy(item => item.index / batchSize)
            .Select(group => group.Select(item => item.feature.Clone()).ToList())
            .ToList();

        for (var i = 0; i < batches.Count; i++)
        {
            var batchFeatures = batches[i];
            var featureIds = batchFeatures
                .Select(feature => ReadString(feature, "Id"))
                .Where(id => !string.IsNullOrWhiteSpace(id))
                .ToHashSet(StringComparer.OrdinalIgnoreCase);
            var faceRefs = batchFeatures
                .SelectMany(feature => ReadStringArrayValue(feature, "FaceRefs"))
                .ToHashSet(StringComparer.OrdinalIgnoreCase);
            var batchImages = plan.AvailableImages
                .Where(image =>
                {
                    var featureId = ReadString(image, "highlight_feature_id");
                    var kind = ReadString(image, "image_kind");
                    var imageFaceRefs = ReadStringArrayValue(image, "highlight_face_refs").ToHashSet(StringComparer.OrdinalIgnoreCase);
                    return kind.Equals("functional_group_all_face_highlight", StringComparison.OrdinalIgnoreCase) &&
                           (imageFaceRefs.Count == 0 || imageFaceRefs.Overlaps(faceRefs) || string.IsNullOrWhiteSpace(featureId) || featureIds.Contains(featureId));
                })
                .Select(image => image.Clone())
                .ToList();

            var batch = new ComponentPromptPlan
            {
                ComponentId = plan.ComponentId,
                InstanceName = plan.InstanceName,
                DisplayName = plan.DisplayName,
                DocumentKind = plan.DocumentKind,
                EvidenceDirectory = plan.EvidenceDirectory,
                EvidencePath = Path.Combine(plan.EvidenceDirectory, $"prompt_input_batch_{i + 1:00}.json"),
                Component = plan.Component.Clone(),
                Package = plan.Package.Clone(),
                PartImagePlan = plan.PartImagePlan.Clone(),
                Features = batchFeatures,
                Faces = plan.Faces.Where(face => faceRefs.Contains(FaceEvidenceRef(face))).Select(face => face.Clone()).ToList(),
                Contacts = plan.Contacts.Select(contact => contact.Clone()).ToList(),
                Mates = plan.Mates.Select(mate => mate.Clone()).ToList(),
                ContextImagePlans = plan.ContextImagePlans.Select(context => context.Clone()).ToList(),
                AvailableImages = batchImages,
                AdjacentComponentIds = plan.AdjacentComponentIds.ToList(),
                NeighborSummaries = plan.NeighborSummaries.Select(neighbor => neighbor.Clone()).ToList(),
                NeighborPackages = plan.NeighborPackages.Select(neighbor => neighbor.Clone()).ToList(),
                NeighborComponents = plan.NeighborComponents.Select(neighbor => neighbor.Clone()).ToList(),
                NeighborPartImagePlans = plan.NeighborPartImagePlans.Select(neighbor => neighbor.Clone()).ToList(),
                FunctionalBlockId = plan.FunctionalBlockId,
                PartRole = plan.PartRole,
                PartFunctionProfile = plan.PartFunctionProfile,
                FunctionalBlockContext = plan.FunctionalBlockContext,
                AssemblyFunctionProfile = CloneJsonObject(plan.AssemblyFunctionProfile),
                FunctionalGroupContext = CloneJsonNode(plan.FunctionalGroupContext) as JsonObject ?? EmptyFunctionalGroupContext(plan.ComponentId),
                ProductDescription = plan.ProductDescription,
                UserPartFunctionProfile = plan.UserPartFunctionProfile,
                PurchasedComponentHints = plan.PurchasedComponentHints.ToList(),
                StaticScoutRules = plan.StaticScoutRules,
                CoarseScoutScan = plan.CoarseScoutScan,
                CoarseScoutScanPath = plan.CoarseScoutScanPath,
                CoarseScoutCardQueryText = plan.CoarseScoutCardQueryText,
                MatchedInspectionCards = plan.MatchedInspectionCards.ToList(),
                BatchId = $"{plan.ComponentId}_batch_{i + 1:00}",
                BatchIndex = i + 1,
                BatchCount = batches.Count,
                BatchFeatureIds = featureIds.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToList()
            };
            batch.CoverageSeeds = BuildCoverageSeeds(batch);
            result.Add(batch);
        }

        return result;
    }

    static async Task<AiCallResult?> TryInterpretPerComponentAsync(
        string root,
        string sectionBrepExtractorProject,
        string model,
        string structureGraphPath,
        string outputDir,
        int maxIterations,
        HttpClient http,
        string baseUrl,
        string apiMode,
        int maxImages)
    {
        var plans = BuildComponentPromptPlans(structureGraphPath, outputDir);
        if (plans.Count == 0)
            return null;

        var diagnosticContext = BuildDiagnosticContextArtifacts(structureGraphPath, outputDir, plans);
        await GenerateComponentFunctionIntroductionsBeforeLocalSubgraphsAsync(
            model,
            outputDir,
            plans,
            diagnosticContext,
            http,
            baseUrl,
            apiMode,
            maxImages);
        var groupRelationshipArtifacts = WriteFunctionalGroupRelationshipArtifacts(outputDir, plans);
        diagnosticContext.FunctionalGroupRelationshipsPath = groupRelationshipArtifacts.JsonPath;
        diagnosticContext.FunctionalGroupRelationshipsMarkdownPath = groupRelationshipArtifacts.MarkdownPath;
        var groupEvidenceManifestPath = WriteFunctionalGroupEvidenceArtifacts(root, sectionBrepExtractorProject, outputDir, plans);
        diagnosticContext.FunctionalGroupEvidenceManifestPath = groupEvidenceManifestPath;
        await GenerateInterfaceAnchorAnalysisAsync(
            model,
            outputDir,
            plans,
            diagnosticContext,
            http,
            baseUrl,
            apiMode,
            maxImages);
        await GenerateFunctionalGroupDescriptionsAsync(
            model,
            outputDir,
            plans,
            diagnosticContext,
            http,
            baseUrl,
            apiMode,
            maxImages);
        ExportInterfaceFunctionDescriptions(outputDir, plans, diagnosticContext);
        await GenerateFunctionalGroupLocalSubgraphsAsync(
            model,
            outputDir,
            plans,
            diagnosticContext,
            http,
            baseUrl,
            apiMode);
        foreach (var plan in plans)
        {
            WriteComponentEvidenceDirectory(outputDir, plan);
            WriteComponentPromptPayloadFile(plan, includeNeighborDetails: false, requestedNeighborIds: [], "prompt_input_round1.json");
        }

        var defaultFeatureBatchSize = Math.Clamp(maxImages > 0 ? maxImages - 1 : 5, 1, 6);
        var featureBatchSize = ReadInt(Environment.GetEnvironmentVariable("FEATURE_BATCH_SIZE"), defaultFeatureBatchSize, 1, 12);
        var workPlans = plans.SelectMany(plan => BuildFeatureBatchPromptPlans(plan, featureBatchSize)).ToList();
        if (workPlans.Count == 0)
            workPlans = plans;

        string promptsDir = Path.Combine(outputDir, "per_component_prompts");
        string aiDir = Path.Combine(outputDir, "per_component_ai");
        Directory.CreateDirectory(promptsDir);
        Directory.CreateDirectory(aiDir);
        string workQueuePath = Path.Combine(outputDir, "component_work_queue.json");
        File.WriteAllText(workQueuePath, JsonSerializer.Serialize(new
        {
            schema = "component_local_subgraph_work_queue_v1",
            source_structure_graph_path = structureGraphPath,
            component_count = plans.Count,
            diagnostic_context = new
            {
                assembly_structure_summary_path = diagnosticContext.AssemblyStructureSummaryPath,
                functional_blocks_path = diagnosticContext.FunctionalBlocksPath,
                part_function_profiles_path = diagnosticContext.PartFunctionProfilesPath,
                functional_group_relationships_path = diagnosticContext.FunctionalGroupRelationshipsPath,
                functional_group_evidence_manifest_path = diagnosticContext.FunctionalGroupEvidenceManifestPath,
                functional_group_descriptions_path = diagnosticContext.FunctionalGroupDescriptionsPath,
                interface_function_descriptions_path = diagnosticContext.InterfaceFunctionDescriptionsPath,
                interface_function_descriptions_markdown_path = diagnosticContext.InterfaceFunctionDescriptionsMarkdownPath,
                functional_group_local_subgraphs_path = diagnosticContext.FunctionalGroupLocalSubgraphsPath,
                static_scout_rules_path = diagnosticContext.StaticScoutRulesPath,
                inspection_cards_path = diagnosticContext.InspectionCardsPath,
                part_static_scout_rules_path = diagnosticContext.PartStaticScoutRulesPath,
                part_inspection_cards_path = diagnosticContext.PartInspectionCardsPath,
                functional_group_static_scout_rules_path = diagnosticContext.FunctionalGroupStaticScoutRulesPath,
                functional_group_inspection_cards_path = diagnosticContext.FunctionalGroupInspectionCardsPath
            },
            components = plans.Select(plan => new
            {
                plan.ComponentId,
                plan.InstanceName,
                plan.DisplayName,
                feature_count = plan.Features.Count,
                feature_batch_size = featureBatchSize,
                feature_batch_count = workPlans.Count(work => work.ComponentId.Equals(plan.ComponentId, StringComparison.OrdinalIgnoreCase)),
                face_count = plan.Faces.Count,
                contact_count = plan.Contacts.Count,
                mate_count = plan.Mates.Count,
                image_count = plan.AvailableImages.Count,
                functional_block_id = plan.FunctionalBlockId,
                part_role = plan.PartRole,
                dynamic_card_selection = "static scout coarse scan -> card_query_text -> top 3 inspection cards",
                inspection_card_count = plan.MatchedInspectionCards.Count,
                evidence_path = plan.EvidencePath,
                adjacent_component_ids = plan.AdjacentComponentIds
            }),
            sequence_policy = "Functional-group local subgraphs are generated before this queue. Component work items run after group-level checking context exists and must perform part/feature-level checks only."
        }, JsonOptions.Default), new UTF8Encoding(false));
        string progressPath = Path.Combine(outputDir, "local_subgraph_progress.json");

        var componentResults = new List<ComponentAiResult>();
        for (var componentIndex = 0; componentIndex < workPlans.Count; componentIndex++)
        {
            var plan = workPlans[componentIndex];
            var result = new ComponentAiResult
            {
                ComponentId = plan.ComponentId,
                InstanceName = plan.InstanceName,
                DisplayName = plan.DisplayName,
                BatchId = plan.BatchId,
                BatchIndex = plan.BatchIndex,
                BatchCount = plan.BatchCount,
                BatchFeatureIds = plan.BatchFeatureIds.ToList()
            };
            componentResults.Add(result);
            WriteLocalSubgraphProgress(progressPath, workPlans.Count, componentIndex, plan, 0, "starting", "Preparing component feature-batch local-subgraph input evidence.");

            var currentPrompt = BuildComponentLocalSubgraphPrompt(plan, includeNeighborDetails: false, requestedNeighborIds: []);
            var promptPath = Path.Combine(promptsDir, $"{SanitizeFileName(FirstNonEmpty(plan.BatchId, plan.ComponentId))}.md");
            File.WriteAllText(promptPath, currentPrompt, new UTF8Encoding(false));
            result.PromptPath = promptPath;
            WriteComponentPromptPayloadFile(plan, includeNeighborDetails: false, requestedNeighborIds: [], $"prompt_input_{SanitizeFileName(FirstNonEmpty(plan.BatchId, "batch"))}.json");

            var includeNeighborDetails = false;
            IReadOnlyList<string> requestedNeighborIds = [];
            var effectiveMaxIterations = ClampComponentLocalSubgraphIterations(maxIterations);
            for (int iteration = 1; ShouldRunComponentLocalSubgraphIteration(iteration, effectiveMaxIterations); iteration++)
            {
                var imagePaths = apiMode == "chat_completions" && iteration == 1
                    ? CollectComponentImagePaths(outputDir, plan, includeNeighborDetails ? requestedNeighborIds : [], maxImages)
                    : [];
                WriteLocalSubgraphProgress(
                    progressPath,
                    workPlans.Count,
                    componentIndex,
                    plan,
                    iteration,
                    includeNeighborDetails ? "generating_with_neighbor_detail" : "generating",
                    includeNeighborDetails
                        ? $"Adding requested neighbor evidence and regenerating local subgraph: {string.Join(", ", requestedNeighborIds)}"
                        : "Sending one feature batch with scoped B-rep, images, and direct relations to AI for local-subgraph generation.");
                object request = apiMode == "responses"
                    ? BuildResponsesRequest(model, currentPrompt)
                    : BuildChatCompletionsRequest(model, currentPrompt, imagePaths);
                string endpoint = apiMode == "responses" ? $"{baseUrl}/responses" : $"{baseUrl}/chat/completions";

                using var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions.Default), Encoding.UTF8, "application/json");
                using var response = await http.PostAsync(endpoint, content);
                string raw = await response.Content.ReadAsStringAsync();
                string rawPath = Path.Combine(aiDir, $"{SanitizeFileName(FirstNonEmpty(plan.BatchId, plan.ComponentId))}_iter{iteration}_response.json");
                File.WriteAllText(rawPath, raw, new UTF8Encoding(false));
                result.RawResponsePaths.Add(rawPath);

                if (!response.IsSuccessStatusCode)
                {
                    result.Status = "error";
                    result.Message = $"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}";
                    WriteLocalSubgraphProgress(progressPath, plans.Count, componentIndex, plan, iteration, "error", result.Message);
                    result.Iterations.Add(new
                    {
                        iteration,
                        attached_images = imagePaths.Count,
                        status = result.Status,
                        result.Message
                    });
                    break;
                }

                var text = ExtractResponseText(raw);
                result.ResponseText = text;
                var localJson = ExtractJsonObjectWithSchema(text, "mechanical_component_local_subgraph_v1")
                    ?? ExtractJsonObjectWithSchema(text, "mechanical_semantic_graph_v1");
                localJson = NormalizeComponentLocalSubgraphJson(localJson, plan);
                var issues = ValidateComponentLocalSubgraphContract(localJson, plan);
                var neighborRequest = ExtractNeighborDetailRequest(localJson, plan);
                result.Iterations.Add(new
                {
                    iteration,
                    attached_images = imagePaths.Count,
                    include_neighbor_details = includeNeighborDetails,
                    requested_neighbor_ids = requestedNeighborIds,
                    contract_issues = issues,
                    neighbor_detail_request = neighborRequest
                });

                result.LocalSubgraphJson = localJson ?? "";
                result.Status = issues.Count == 0 ? "ok" : "contract_issues";
                result.Message = issues.Count == 0 ? "generated" : string.Join("; ", issues);
                WriteLocalSubgraphProgress(progressPath, plans.Count, componentIndex, plan, iteration, result.Status, result.Message);
            }

            string textPath = Path.Combine(aiDir, $"{SanitizeFileName(FirstNonEmpty(plan.BatchId, plan.ComponentId))}_interpretation.md");
            File.WriteAllText(textPath, result.ResponseText, new UTF8Encoding(false));
            result.ResultPath = textPath;
        }

        var aggregateJson = BuildAggregatedMechanicalSemanticGraph(structureGraphPath, plans, componentResults, diagnosticContext.FunctionalGroupLocalSubgraphsPath);
        string semanticGraphPath = Path.Combine(outputDir, "mechanical_semantic_graph.json");
        File.WriteAllText(semanticGraphPath, NormalizeJson(aggregateJson), new UTF8Encoding(false));
        var functionIntroArtifacts = WriteComponentFunctionIntroductionArtifacts(outputDir, plans, componentResults);

        string collectionPath = Path.Combine(outputDir, "local_subgraph_collection.json");
        File.WriteAllText(collectionPath, BuildLocalSubgraphCollection(plans, componentResults), new UTF8Encoding(false));

        string resultPath = Path.Combine(outputDir, "ai_interpretation.md");
        File.WriteAllText(resultPath, BuildPerComponentInterpretationMarkdown(componentResults), new UTF8Encoding(false));

        var contractIssues = ValidateRetrievalContract(aggregateJson);
        string evolutionPath = Path.Combine(outputDir, "ai_self_evolution.json");
        File.WriteAllText(evolutionPath, JsonSerializer.Serialize(new
        {
            Model = model,
            Mode = "per_component_local_subgraph_generation",
            ComponentCount = plans.Count,
            FeatureBatchSize = featureBatchSize,
            FeatureBatchCount = workPlans.Count,
            CompletedFeatureBatches = componentResults.Count(r => r.Status == "ok"),
            CompletedComponents = componentResults.Where(r => r.Status == "ok").Select(r => r.ComponentId).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
            FailedComponents = componentResults.Where(r => r.Status != "ok").Select(r => new { r.ComponentId, r.DisplayName, r.Status, r.Message }),
            RetrievalContractIssues = contractIssues,
            MechanicalSemanticGraphStatus = contractIssues.Count == 0 ? "parsed" : "parsed_with_contract_issues",
            MechanicalSemanticGraphPath = semanticGraphPath,
            ComponentFunctionIntroductionsJsonPath = functionIntroArtifacts.JsonPath,
            ComponentFunctionIntroductionsMarkdownPath = functionIntroArtifacts.MarkdownPath,
            LocalSubgraphCollectionPath = collectionPath,
            ComponentWorkQueuePath = workQueuePath,
            Components = componentResults.Select(r => new
            {
                r.ComponentId,
                r.InstanceName,
                r.DisplayName,
                r.BatchId,
                r.BatchIndex,
                r.BatchCount,
                r.BatchFeatureIds,
                r.Status,
                r.Message,
                r.PromptPath,
                r.EnrichedPromptPath,
                r.ResultPath,
                r.RawResponsePaths,
                r.Iterations
            })
        }, JsonOptions.Default), new UTF8Encoding(false));

        return new AiCallResult
        {
            Status = "ok",
            Model = model,
            Message = contractIssues.Count == 0
                ? $"Per-component local subgraphs generated for {componentResults.Count(r => r.Status == "ok")}/{workPlans.Count} feature batches across {plans.Count} components."
                : $"Per-component local subgraphs generated with retrieval contract issues: {string.Join("; ", contractIssues.Take(8))}",
            ResultPath = resultPath,
            RawResponsePath = componentResults.SelectMany(r => r.RawResponsePaths).FirstOrDefault() ?? "",
            SelfEvolutionPath = evolutionPath,
            MechanicalSemanticGraphPath = semanticGraphPath,
            MechanicalSemanticGraphStatus = contractIssues.Count == 0 ? "parsed" : "parsed_with_contract_issues"
        };
    }

    static DiagnosticContextArtifacts BuildDiagnosticContextArtifacts(string structureGraphPath, string outputDir, IReadOnlyList<ComponentPromptPlan> plans)
    {
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);

        var assemblySummaryPath = Path.Combine(contextDir, "assembly_structure_summary.json");
        var functionalBlocksPath = Path.Combine(contextDir, "functional_blocks.json");
        var partProfilesPath = Path.Combine(contextDir, "part_function_profiles.json");
        var staticScoutRulesPath = Path.Combine(contextDir, "static_scout_rules_part_knowledge.md");
        var functionalGroupStaticScoutRulesPath = Path.Combine(contextDir, "static_scout_rules_assembly_knowledge.md");
        var inspectionCardsPath = Path.Combine(contextDir, "inspection_cards_part_knowledge.json");
        var functionalGroupInspectionCardsPath = Path.Combine(contextDir, "inspection_cards_assembly_knowledge.json");

        var assemblySummary = BuildAssemblyStructureSummary(structureGraphPath, plans);
        WriteJsonFile(assemblySummaryPath, assemblySummary);

        var staticScoutRules = BuildStaticScoutRules();
        File.WriteAllText(staticScoutRulesPath, staticScoutRules, new UTF8Encoding(false));
        var functionalGroupStaticScoutRules = BuildFunctionalGroupStaticScoutRules();
        File.WriteAllText(functionalGroupStaticScoutRulesPath, functionalGroupStaticScoutRules, new UTF8Encoding(false));

        var partOnly = plans.Count > 0 && plans.All(plan => plan.IsPartDocument);
        var functionContext = partOnly
            ? new FunctionContext()
            : BuildHeuristicFunctionContext(plans);
        WriteJsonFile(functionalBlocksPath, new
        {
            schema = partOnly ? "part_function_context_disabled_v1" : "assembly_function_blocks_v1",
            generation_mode = partOnly ? "disabled_for_part_input" : "heuristic_from_component_names_and_assembly_graph",
            usage_policy = partOnly
                ? "Part input has no assembly functional-positioning stage. Use B-rep, images, coarse scout signatures, and selected inspection cards only."
                : "Low-resolution functional context for local diagnostic evidence extraction. Not a diagnostic judgement.",
            functionContext.Blocks
        });
        WriteJsonFile(partProfilesPath, new
        {
            schema = partOnly ? "part_role_context_disabled_v1" : "part_function_profiles_v1",
            generation_mode = partOnly ? "disabled_for_part_input" : "heuristic_from_component_names_and_assembly_graph",
            usage_policy = partOnly
                ? "Part input has no functional role profile. Local units must be grounded by target B-rep, images, and selected inspection cards."
                : "Part-level role context only. Local face/hole/feature roles must still be inferred from local B-rep and direct relations.",
            functionContext.Profiles
        });

        var cards = BuildInspectionCardsFromKnowledgeBase("chapter18_rule_store_v1.json", "chapter18_rule_index_v1.json", rule => RuleBelongsToChapter(rule, 18));
        WriteJsonFile(inspectionCardsPath, new
        {
            schema = "inspection_cards_part_knowledge_v1",
            generation_mode = "structured_from_part_knowledge_sources",
            seed_sources = new[] { "chapter18_rule_store_v1.json" },
            usage_policy = "Cards guide targeted part-level evidence extraction. Full source rules must still be looked up for exceptions, repair suggestions, and final judgement. This knowledge library can be expanded beyond its current seed chapters.",
            cards = cards.Select(card => card.Dto).ToList()
        });
        var functionalGroupCards = BuildInspectionCardsFromKnowledgeBase("chapter19_24_rule_store_v1.json", "chapter19_24_rule_index_v1.json", rule => RuleBelongsToChapter(rule, 19));
        WriteJsonFile(functionalGroupInspectionCardsPath, new
        {
            schema = "inspection_cards_assembly_knowledge_v1",
            generation_mode = "structured_from_assembly_knowledge_sources",
            seed_sources = new[] { "chapter19_24_rule_store_v1.json filtered to chapter 19" },
            usage_policy = "Cards guide assembly/function-group evidence extraction. Full source rules must still be retrieved for detailed final judgement. This knowledge library can be expanded beyond its current seed chapters.",
            cards = functionalGroupCards.Select(card => card.Dto).ToList()
        });

        var profilesByComponent = functionContext.Profiles
            .ToDictionary(profile => profile.ComponentId, StringComparer.OrdinalIgnoreCase);
        var blocksById = functionContext.Blocks
            .ToDictionary(block => block.BlockId, StringComparer.OrdinalIgnoreCase);

        foreach (var plan in plans)
        {
            profilesByComponent.TryGetValue(plan.ComponentId, out var profile);
            plan.PartFunctionProfile = profile?.ToDto() ?? new
            {
                component_id = plan.ComponentId,
                component_name = plan.InstanceName,
                part_role = "unidentified general mechanical part",
                confidence = "low"
            };
            plan.FunctionalBlockId = profile?.FunctionalBlockId ?? "";
            plan.PartRole = profile?.PartRole ?? "";
            if (profile != null && blocksById.TryGetValue(profile.FunctionalBlockId, out var block))
                plan.FunctionalBlockContext = block.ToDto();
            else
                plan.FunctionalBlockContext = new { block_id = "", name = "鏈綊绫诲姛鑳藉潡", confidence = "low" };

            if (plan.IsPartDocument)
            {
                plan.PartFunctionProfile = BuildUserPartFunctionProfile(plan);
                plan.AssemblyFunctionProfile = JsonSerializer.SerializeToNode(plan.PartFunctionProfile, JsonOptions.Default) as JsonObject;
                plan.FunctionalBlockId = "";
                plan.PartRole = plan.AssemblyFunctionProfile == null ? "" : NodeString(plan.AssemblyFunctionProfile, "part_role");
                plan.FunctionalBlockContext = new
                {
                    context_policy = "disabled_for_part_input",
                    reason = "A single part has no assembly functional-positioning stage in this workflow; use user_part_function_profile as the function portrait."
                };
            }

            plan.StaticScoutRules = staticScoutRules;
            var coarseScan = BuildStaticScoutCoarseScan(plan);
            plan.CoarseScoutScan = coarseScan.Dto;
            plan.CoarseScoutCardQueryText = coarseScan.CardQueryText;
            plan.CoarseScoutScanPath = Path.Combine(plan.EvidenceDirectory, "coarse_feature_scan.json");
            WriteJsonFile(plan.CoarseScoutScanPath, coarseScan.Dto);
            plan.MatchedInspectionCards = SelectInspectionCardsForPlan(plan, cards, maxCards: 3);
            WriteComponentPromptPayloadFile(plan, includeNeighborDetails: false, requestedNeighborIds: [], "prompt_input_round1.json");
        }

        return new DiagnosticContextArtifacts
        {
            AssemblyStructureSummaryPath = assemblySummaryPath,
            FunctionalBlocksPath = functionalBlocksPath,
            PartFunctionProfilesPath = partProfilesPath,
            StaticScoutRulesPath = staticScoutRulesPath,
            InspectionCardsPath = inspectionCardsPath,
            PartStaticScoutRulesPath = staticScoutRulesPath,
            FunctionalGroupStaticScoutRulesPath = functionalGroupStaticScoutRulesPath,
            PartInspectionCardsPath = inspectionCardsPath,
            FunctionalGroupInspectionCardsPath = functionalGroupInspectionCardsPath
        };
    }

    static async Task GenerateComponentFunctionIntroductionsBeforeLocalSubgraphsAsync(
        string model,
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        DiagnosticContextArtifacts diagnosticContext,
        HttpClient http,
        string baseUrl,
        string apiMode,
        int maxImages)
    {
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);
        var jsonPath = Path.Combine(contextDir, "component_function_introductions.json");
        var markdownPath = Path.Combine(contextDir, "component_function_introductions.md");
        var promptPath = Path.Combine(contextDir, "component_function_introductions_prompt.md");
        var rawPath = Path.Combine(contextDir, "component_function_introductions_response.json");
        diagnosticContext.ComponentFunctionIntroductionsPath = jsonPath;
        diagnosticContext.ComponentFunctionIntroductionsMarkdownPath = markdownPath;

        if (plans.Count == 0)
            return;

        if (plans.All(plan => plan.IsPartDocument))
        {
            var disabled = BuildFallbackComponentFunctionIntroductions(plans, "disabled_for_part_input", "Part input has no assembly-level component function stage.");
            WriteJsonFile(jsonPath, disabled);
            File.WriteAllText(markdownPath, BuildComponentFunctionIntroductionMarkdown(disabled), new UTF8Encoding(false));
            AssignComponentFunctionProfiles(plans, disabled);
            return;
        }

        var prompt = BuildComponentFunctionIntroductionPrompt(plans, diagnosticContext);
        File.WriteAllText(promptPath, prompt, new UTF8Encoding(false));

        try
        {
            var componentFunctionHighlightImagePaths = CollectComponentFunctionHighlightImagePaths(plans, maxImages: 12);
            object request = apiMode == "responses"
                ? BuildResponsesRequest(model, prompt, componentFunctionHighlightImagePaths)
                : BuildChatCompletionsRequest(model, prompt, componentFunctionHighlightImagePaths);
            string endpoint = apiMode == "responses" ? $"{baseUrl}/responses" : $"{baseUrl}/chat/completions";

            using var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions.Default), Encoding.UTF8, "application/json");
            using var response = await http.PostAsync(endpoint, content);
            var raw = await response.Content.ReadAsStringAsync();
            File.WriteAllText(rawPath, raw, new UTF8Encoding(false));

            if (!response.IsSuccessStatusCode)
                throw new InvalidOperationException($"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}");

            var text = ExtractResponseText(raw);
            var json = ExtractJsonObjectWithSchema(text, "component_function_introductions_v1");
            if (string.IsNullOrWhiteSpace(json))
                throw new InvalidOperationException("component_function_introductions_v1 JSON was not found in AI response.");

            File.WriteAllText(jsonPath, NormalizeComponentFunctionIntroductionsJson(json), new UTF8Encoding(false));
            using var doc = JsonDocument.Parse(File.ReadAllText(jsonPath));
            var root = doc.RootElement.Clone();
            File.WriteAllText(markdownPath, BuildComponentFunctionIntroductionMarkdown(root), new UTF8Encoding(false));
            AssignComponentFunctionProfiles(plans, root);
        }
        catch (Exception ex)
        {
            var fallback = BuildFallbackComponentFunctionIntroductions(plans, "diagnostic_context_fallback", ex.Message);
            WriteJsonFile(jsonPath, fallback);
            File.WriteAllText(markdownPath, BuildComponentFunctionIntroductionMarkdown(fallback), new UTF8Encoding(false));
            AssignComponentFunctionProfiles(plans, fallback);
        }
    }

    static string BuildComponentFunctionIntroductionPrompt(
        IReadOnlyList<ComponentPromptPlan> plans,
        DiagnosticContextArtifacts diagnosticContext)
    {
        var payload = new
        {
            schema = "component_function_introduction_input_v1",
            task = "Before generating local diagnostic subgraphs, infer each listed component's role in the whole assembly.",
            product_description = plans.Select(plan => plan.ProductDescription).FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)) ?? "",
            purchased_component_hints = plans.SelectMany(plan => plan.PurchasedComponentHints).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
            diagnostic_context_files = new
            {
                assembly_structure_summary = diagnosticContext.AssemblyStructureSummaryPath,
                functional_blocks = diagnosticContext.FunctionalBlocksPath,
                part_function_profiles = diagnosticContext.PartFunctionProfilesPath
            },
            components = plans.Select(plan => new
            {
                plan.ComponentId,
                plan.InstanceName,
                plan.DisplayName,
                review_scope = ReadString(plan.Package, "ReviewScope"),
                category = ReadString(plan.Component, "Category"),
                file_name = FirstNonEmpty(ReadString(plan.Component, "FileName"), Path.GetFileName(ReadString(plan.Package, "Path"))),
                feature_count = plan.Features.Count,
                face_count = plan.Faces.Count,
                contact_count = plan.Contacts.Count,
                mate_count = plan.Mates.Count,
                adjacent_component_ids = plan.AdjacentComponentIds,
                adjacent_components = plan.NeighborSummaries,
                part_function_profile = plan.PartFunctionProfile,
                major_feature_summaries = plan.Features.Take(12).Select(feature => new
                {
                    id = ReadString(feature, "Id"),
                    type = ReadString(feature, "Type"),
                    geometry = feature.TryGetProperty("Geometry", out var geometry) ? CompactJson(geometry, 600) : "",
                    face_refs = ReadStringArrayValue(feature, "FaceRefs").Take(8).ToList()
                }).ToList(),
                direct_contacts = plan.Contacts.Take(12).Select(contact => new
                {
                    id = ReadString(contact, "Id"),
                    component_a = ReadString(contact, "ComponentA"),
                    component_b = ReadString(contact, "ComponentB"),
                    face_a = ReadInt(contact, "FaceA"),
                    face_b = ReadInt(contact, "FaceB")
                }).ToList(),
                direct_mates = plan.Mates.Take(12).Select(mate => new
                {
                    id = ReadString(mate, "Id"),
                    type = FirstNonEmpty(ReadString(mate, "Type"), ReadString(mate, "MateType")),
                    component_a = ReadString(mate, "ComponentA"),
                    component_b = ReadString(mate, "ComponentB")
                }).ToList(),
                component_position_images = SelectComponentFunctionPositionImages(plan, 6)
                    .Select(image => new
                    {
                        evidence_name = ReadString(image, "evidence_name"),
                        image_kind = ReadString(image, "image_kind"),
                        evidence_family = ReadString(image, "evidence_family"),
                        position_visibility = ReadString(image, "position_visibility"),
                        view_name = ReadString(image, "view_name"),
                        output_path = ReadString(image, "output_path")
                    })
                    .Take(6)
                    .ToList(),
                excluded_image_policy = "Part/feature all-face highlight images are not used for function portrait generation; they are reserved for later part-style fault checking."
            }).ToList()
        };
        var json = JsonSerializer.Serialize(payload, JsonOptions.Default);
        return $$"""
You are a mechanical assembly function analyst.
Before any local fault-retrieval subgraphs are generated, infer the role of each target component in the whole device.

Rules:
- Answer in Chinese.
- Preserve exact SolidWorks component names.
- Do not judge design errors.
- Ground each role in direct contacts, mates, B-rep feature summaries, adjacent components, product_description, and component_position_images.
- component_position_images is one evidence family with two visibility variants: unobstructed shows the target component without blockers; occluded_context preserves required neighboring or blocking components to show the target's real internal assembly environment. Use both variants together to infer component position and whole-component function.
- Never use assembly_physical_interface images or functional_group_all_face_highlight images for the whole-component function portrait.
- Do not use all-face or per-face highlight images for this function portrait stage. Those images are reserved for later part-level checking.
- Purchased/standard components may be mentioned as context, but do not create part-design diagnostics for them.
- Return exactly one fenced JSON object. No prose outside the JSON block.

Return schema:
```json
{
  "schema": "component_function_introductions_v1",
  "generation_mode": "pre_local_subgraph_ai_from_assembly_context",
  "components": [
    {
      "component_id": "component_1",
      "instance_name": "exact SolidWorks instance name",
      "display_name": "exact display name",
      "function_introduction": "1-3 Chinese sentences explaining what this component does in the assembly",
      "assembly_function": "定位/支撑/连接/传动/密封/导向/限位等装配功能",
      "related_components": ["directly related component names"],
      "interface_relations": ["contacts, mates, coaxial/flush/support relations supporting the role"],
      "responsibilities": ["only evidence-supported responsibilities"],
      "evidence_refs": ["real component ids, contact ids, mate ids, feature ids, or whole-component highlight image names"],
      "confidence": "low|medium|high"
    }
  ]
}
```

Input evidence:
```json
{{json}}
```
""";
    }

    static string NormalizeComponentFunctionIntroductionsJson(string json)
    {
        try
        {
            var root = JsonNode.Parse(json) as JsonObject;
            if (root == null)
                return NormalizeJson(json);

            root["schema"] = "component_function_introductions_v1";
            root["generation_mode"] = FirstNonEmpty(NodeString(root, "generation_mode"), "pre_local_subgraph_ai_from_assembly_context");
            root["image_policy"] = "Uses only evidence_family=assembly_component_position for component function portraits, including unobstructed and occluded_context variants. Excludes physical-interface and all-face images.";

            if (root["components"] is JsonArray components)
            {
                foreach (var component in components.OfType<JsonObject>())
                {
                    component["function_portrait_image_policy"] = "assembly_component_position_family_only";
                    component["related_components"] ??= new JsonArray();
                    component["interface_relations"] ??= new JsonArray();
                    component["responsibilities"] ??= new JsonArray();
                    component["evidence_refs"] ??= new JsonArray();
                }
            }

            return root.ToJsonString(JsonOptions.Default);
        }
        catch
        {
            return NormalizeJson(json);
        }
    }

    static List<JsonElement> ComponentFunctionHighlightImages(ComponentPromptPlan plan)
    {
        return plan.AvailableImages
            .Where(IsWholeComponentFunctionHighlightImage)
            .Select(image => image.Clone())
            .ToList();
    }

    static List<JsonElement> SelectComponentFunctionPositionImages(ComponentPromptPlan plan, int maxImages)
    {
        if (maxImages <= 0)
            return [];

        var candidates = ComponentFunctionHighlightImages(plan);
        var selected = new List<JsonElement>();
        foreach (var visibility in new[] { "unobstructed", "occluded_context" })
        {
            var candidate = candidates.FirstOrDefault(image =>
                ComponentPositionVisibility(image).Equals(visibility, StringComparison.OrdinalIgnoreCase));
            if (candidate.ValueKind != JsonValueKind.Undefined)
                selected.Add(candidate);
        }

        selected.AddRange(candidates.Where(candidate => selected.All(existing =>
            !ComponentPositionImageKey(existing).Equals(ComponentPositionImageKey(candidate), StringComparison.OrdinalIgnoreCase))));
        return selected.Take(maxImages).ToList();
    }

    static string ComponentPositionVisibility(JsonElement image)
    {
        var visibility = ReadString(image, "position_visibility");
        if (!string.IsNullOrWhiteSpace(visibility))
            return visibility;

        var kind = ReadString(image, "image_kind");
        var path = ReadString(image, "output_path").Replace('\\', '/');
        return kind.Equals("assembly_component_context_isolate_view", StringComparison.OrdinalIgnoreCase) ||
               path.Contains("/component_contexts/", StringComparison.OrdinalIgnoreCase)
            ? "occluded_context"
            : "unobstructed";
    }

    static string ComponentPositionImageKey(JsonElement image) => FirstNonEmpty(
        ReadString(image, "output_path"),
        ReadString(image, "evidence_name"),
        $"{ReadString(image, "image_kind")}:{ReadString(image, "view_name")}");

    static bool IsWholeComponentFunctionHighlightImage(JsonElement image)
    {
        var family = ReadString(image, "evidence_family");
        if (!string.IsNullOrWhiteSpace(family))
            return family.Equals("assembly_component_position", StringComparison.OrdinalIgnoreCase);

        var kind = ReadString(image, "image_kind");
        var path = ReadString(image, "output_path").Replace('\\', '/');
        return kind.Equals("assembly_component_highlight_view", StringComparison.OrdinalIgnoreCase) ||
               kind.Equals("assembly_component_context_isolate_view", StringComparison.OrdinalIgnoreCase) ||
               path.Contains("/component_highlights/", StringComparison.OrdinalIgnoreCase) ||
               path.Contains("/component_contexts/", StringComparison.OrdinalIgnoreCase);
    }

    static List<string> CollectComponentFunctionHighlightImagePaths(IReadOnlyList<ComponentPromptPlan> plans, int maxImages)
    {
        if (maxImages <= 0)
            return [];

        var selected = new List<string>();
        foreach (var plan in plans)
        {
            foreach (var image in SelectComponentFunctionPositionImages(plan, 2))
            {
                var path = ReadString(image, "output_path");
                if (!string.IsNullOrWhiteSpace(path) && File.Exists(path) && !selected.Contains(path, StringComparer.OrdinalIgnoreCase))
                    selected.Add(path);
                if (selected.Count >= maxImages)
                    break;
            }
            if (selected.Count >= maxImages)
                break;
        }

        if (selected.Count < maxImages)
        {
            selected.AddRange(plans
                .SelectMany(ComponentFunctionHighlightImages)
                .Select(image => ReadString(image, "output_path"))
                .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path))
                .Where(path => !selected.Contains(path, StringComparer.OrdinalIgnoreCase))
                .Take(maxImages - selected.Count));
        }

        return selected
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Take(maxImages)
            .ToList();
    }

    static JsonObject BuildFallbackComponentFunctionIntroductions(
        IReadOnlyList<ComponentPromptPlan> plans,
        string generationMode,
        string message)
    {
        var components = new JsonArray();
        foreach (var plan in plans)
        {
            components.Add(new JsonObject
            {
                ["component_id"] = plan.ComponentId,
                ["instance_name"] = plan.InstanceName,
                ["display_name"] = plan.DisplayName,
                ["function_introduction"] = plan.IsPartDocument
                    ? "单零件输入没有装配体功能定位阶段。"
                    : $"该零件为装配体中的 `{plan.DisplayName}`，需要结合 B-rep、图片、接触和配合关系进一步确认功能。",
                ["assembly_function"] = plan.PartRole,
                ["related_components"] = new JsonArray(plan.NeighborSummaries.Select(summary => JsonValue.Create(ReadString(summary, "display_name"))).Where(value => value != null).ToArray<JsonNode?>()),
                ["interface_relations"] = new JsonArray(plan.Contacts.Take(8).Select(contact => JsonValue.Create(ReadString(contact, "Id"))).Where(value => value != null).ToArray<JsonNode?>()),
                ["responsibilities"] = new JsonArray(JsonValue.Create("requires_local_evidence_confirmation")),
                ["evidence_refs"] = new JsonArray(plan.Features.Take(8).Select(feature => JsonValue.Create(ReadString(feature, "Id"))).Where(value => value != null).ToArray<JsonNode?>()),
                ["confidence"] = plan.IsPartDocument ? "not_applicable" : "low"
            });
        }

        return new JsonObject
        {
            ["schema"] = "component_function_introductions_v1",
            ["generation_mode"] = generationMode,
            ["message"] = message,
            ["components"] = components
        };
    }

    static void AssignComponentFunctionProfiles(IReadOnlyList<ComponentPromptPlan> plans, object rootValue)
    {
        var root = JsonSerializer.SerializeToNode(rootValue, JsonOptions.Default) as JsonObject;
        if (root?["components"] is not JsonArray components)
            return;

        var byId = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
        foreach (var item in components)
        {
            if (item is not JsonObject obj)
                continue;
            var id = NodeString(obj, "component_id");
            if (!string.IsNullOrWhiteSpace(id))
                byId[id] = obj;
        }

        foreach (var plan in plans)
        {
            if (byId.TryGetValue(plan.ComponentId, out var profile))
                plan.AssemblyFunctionProfile = CloneJsonObject(profile);
        }
    }

    static string BuildComponentFunctionIntroductionMarkdown(object rootValue)
    {
        var root = JsonSerializer.SerializeToNode(rootValue, JsonOptions.Default) as JsonObject;
        var sb = new StringBuilder();
        sb.AppendLine("# Component Function Introductions");
        sb.AppendLine();
        sb.AppendLine($"Generation mode: {(root == null ? "" : NodeString(root, "generation_mode"))}");
        if (root != null && !string.IsNullOrWhiteSpace(NodeString(root, "message")))
            sb.AppendLine($"Message: {NodeString(root, "message")}");
        sb.AppendLine();
        if (root?["components"] is JsonArray components)
        {
            foreach (var item in components.OfType<JsonObject>())
            {
                sb.AppendLine($"## {NodeString(item, "component_id")} {NodeString(item, "display_name")}");
                sb.AppendLine();
                sb.AppendLine($"- Function: {NodeString(item, "function_introduction")}");
                sb.AppendLine($"- Assembly function: {NodeString(item, "assembly_function")}");
                sb.AppendLine($"- Confidence: {NodeString(item, "confidence")}");
                sb.AppendLine();
            }
        }
        return sb.ToString();
    }

    static (string JsonPath, string MarkdownPath) WriteFunctionalGroupRelationshipArtifacts(
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans)
    {
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);
        var jsonPath = Path.Combine(contextDir, "functional_group_relationships.json");
        var markdownPath = Path.Combine(contextDir, "functional_group_relationships.md");

        var componentLookup = BuildComponentLookup(plans);
        var interfaces = BuildFunctionGroupInterfaces(plans, componentLookup);
        var groupComponentIds = BuildRelationConnectedGroups(plans, interfaces);
        var groups = new JsonArray();
        var markdown = new StringBuilder();
        markdown.AppendLine("# 功能组关系摘要");
        markdown.AppendLine();
        markdown.AppendLine("该文件只保留功能组设计判断所需的接口面：显式配合面，以及由 B-rep 空间关系判定出的接触、贴合、同轴、近零间隙或位置重合候选面；不包含零件全部面信息。");
        markdown.AppendLine();

        var groupIndex = 0;
        foreach (var componentIds in groupComponentIds)
        {
            groupIndex++;
            var groupId = $"functional_group_{groupIndex:000}";
            var memberSet = componentIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
            var groupInterfaces = interfaces
                .OfType<JsonObject>()
                .Where(item =>
                    memberSet.Contains(NodeString(item, "component_a_id")) ||
                    memberSet.Contains(NodeString(item, "component_b_id")))
                .ToList();
            if (groupInterfaces.Count == 0)
                continue;

            var members = new JsonArray();
            foreach (var plan in plans.Where(plan => memberSet.Contains(plan.ComponentId)).OrderBy(plan => plan.ComponentId, StringComparer.OrdinalIgnoreCase))
            {
                members.Add(new JsonObject
                {
                    ["component_id"] = plan.ComponentId,
                    ["instance_name"] = plan.InstanceName,
                    ["display_name"] = plan.DisplayName,
                    ["function_introduction"] = ComponentProfileString(plan, "function_introduction"),
                    ["assembly_function"] = ComponentProfileString(plan, "assembly_function"),
                    ["review_scope"] = ReadString(plan.Package, "ReviewScope")
                });
            }

            var groupInterfaceArray = new JsonArray();
            foreach (var item in groupInterfaces)
                groupInterfaceArray.Add(CloneJsonNode(item));

            groups.Add(new JsonObject
            {
                ["group_id"] = groupId,
                ["grouping_basis"] = "connected_by_brep_geometric_interface_or_solidworks_mate",
                ["diagnostic_scope"] = "functional_group_design_errors",
                ["member_components"] = members,
                ["interfaces"] = groupInterfaceArray,
                ["usage_policy"] = "Use this file to judge function-group design relations. It intentionally contains only explicit mates and B-rep-derived geometric interface faces such as contact, coincident/flush, coaxial, overlap, or near-zero-gap candidates, not all faces of all parts."
            });

            markdown.AppendLine($"## {groupId}");
            markdown.AppendLine();
            markdown.AppendLine("### 零件功能");
            foreach (var plan in plans.Where(plan => memberSet.Contains(plan.ComponentId)).OrderBy(plan => plan.ComponentId, StringComparer.OrdinalIgnoreCase))
            {
                var intro = FirstNonEmpty(
                    ComponentProfileString(plan, "function_introduction"),
                    ComponentProfileString(plan, "assembly_function"),
                    plan.PartRole,
                    "未给出明确功能");
                markdown.AppendLine($"- {plan.ComponentId} {plan.DisplayName}: {intro}");
            }
            markdown.AppendLine();
            markdown.AppendLine("### 关键接口");
            foreach (var item in groupInterfaces)
            {
                markdown.AppendLine($"- {NodeString(item, "relation_id")}：{NodeString(item, "component_a_name")} `{NodeString(item, "face_a_ref")}` <-> {NodeString(item, "component_b_name")} `{NodeString(item, "face_b_ref")}`；关系={NodeString(item, "relation_role")}；证据={NodeString(item, "evidence")}");
            }
            markdown.AppendLine();
        }

        var root = new JsonObject
        {
            ["schema"] = "functional_group_relationships_v1",
            ["generation_mode"] = "deterministic_from_component_functions_contacts_and_mates",
            ["usage_policy"] = "Functional-group design diagnostics should use these relation-scoped interfaces after per-part scans. This is not a complete face inventory.",
            ["face_filter_policy"] = "Include explicit SolidWorks mates plus B-rep-derived geometric interfaces: direct contacts, near-coplanar or flush faces, overlapping planar faces, coaxial cylindrical fit candidates, matching-radius candidates, near-zero-gap contacts, and positionally coincident face pairs. Exclude unrelated faces.",
            ["groups"] = groups
        };
        AssignFunctionalGroupContexts(plans, root);
        File.WriteAllText(jsonPath, root.ToJsonString(JsonOptions.Default), new UTF8Encoding(false));
        File.WriteAllText(markdownPath, markdown.ToString(), new UTF8Encoding(false));
        return (jsonPath, markdownPath);
    }

    static void AssignFunctionalGroupContexts(IReadOnlyList<ComponentPromptPlan> plans, JsonObject relationshipRoot)
    {
        var groups = relationshipRoot["groups"] as JsonArray ?? new JsonArray();
        foreach (var plan in plans)
        {
            var scopedGroups = new JsonArray();
            foreach (var groupNode in groups.OfType<JsonObject>())
            {
                if (!FunctionalGroupContainsComponent(groupNode, plan.ComponentId))
                    continue;
                scopedGroups.Add(CloneJsonNode(groupNode));
            }

            plan.FunctionalGroupContext = new JsonObject
            {
                ["schema"] = "component_functional_group_context_v1",
                ["component_id"] = plan.ComponentId,
                ["instance_name"] = plan.InstanceName,
                ["selection_policy"] = "Only functional groups containing this component are included. Each group contains relation-scoped interfaces only, not all part faces.",
                ["usage_policy"] = "Use this as the functional-group-level diagnostic input before or alongside detailed part-level checks.",
                ["source_schema"] = NodeString(relationshipRoot, "schema"),
                ["face_filter_policy"] = NodeString(relationshipRoot, "face_filter_policy"),
                ["groups"] = scopedGroups
            };
        }
    }

    static JsonObject EmptyFunctionalGroupContext(string componentId = "") => new()
    {
        ["schema"] = "component_functional_group_context_v1",
        ["component_id"] = componentId,
        ["selection_policy"] = "No functional-group relationship context has been assigned.",
        ["usage_policy"] = "No group-level relation evidence is available for this component.",
        ["groups"] = new JsonArray()
    };

    static bool FunctionalGroupContainsComponent(JsonObject group, string componentId)
    {
        if (string.IsNullOrWhiteSpace(componentId))
            return false;
        if (group["member_components"] is not JsonArray members)
            return false;
        foreach (var member in members.OfType<JsonObject>())
        {
            if (NodeString(member, "component_id").Equals(componentId, StringComparison.OrdinalIgnoreCase))
                return true;
        }
        return false;
    }

    static string WriteFunctionalGroupEvidenceArtifacts(string root, string sectionBrepExtractorProject, string outputDir, IReadOnlyList<ComponentPromptPlan> plans)
    {
        var rootDir = Path.Combine(outputDir, "functional_group_evidence");
        Directory.CreateDirectory(rootDir);
        var manifestPath = Path.Combine(rootDir, "manifest.json");
        var imagePlanPath = Path.Combine(rootDir, "all_face_highlight_export_plan.json");
        var imageExportManifestPath = Path.Combine(rootDir, "all_face_highlight_export_manifest.json");
        var planById = plans.ToDictionary(plan => plan.ComponentId, StringComparer.OrdinalIgnoreCase);
        var groups = UniqueFunctionalGroups(plans);
        var manifestGroups = new JsonArray();
        var imagePlanGroups = new JsonArray();

        foreach (var group in groups)
        {
            var groupId = NodeString(group, "group_id");
            if (string.IsNullOrWhiteSpace(groupId))
                continue;
            var groupDir = Path.Combine(rootDir, SanitizeFileName(groupId));
            Directory.CreateDirectory(groupDir);
            var interfaceSubsetPath = Path.Combine(groupDir, "interface_subset.json");
            var partFullRefsPath = Path.Combine(groupDir, "part_full_evidence_refs.json");
            var groupPartBrepPath = Path.Combine(groupDir, "group_part_brep.json");
            var markdownPath = Path.Combine(groupDir, "README.md");
            var memberIds = FunctionalGroupMemberIds(group);
            var memberPlans = memberIds.Where(planById.ContainsKey).Select(id => planById[id]).ToList();
            var interfaceFaceRefs = InterfaceFaceRefsByComponent(group);
            var groupRelativeDir = Path.GetRelativePath(rootDir, groupDir).Replace('\\', '/');

            var memberInterfaces = new JsonArray();
            foreach (var plan in memberPlans)
            {
                interfaceFaceRefs.TryGetValue(plan.ComponentId, out var refs);
                refs ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                var interfaceFaces = plan.Faces
                    .Where(face => refs.Contains(FaceEvidenceRef(face)))
                    .Select(face => face.Clone())
                    .ToList();
                var interfaceImages = FilterImagesByFaceRefs(plan.AvailableImages, refs)
                    .Select(image => image.Clone())
                    .ToList();
                memberInterfaces.Add(new JsonObject
                {
                    ["component_id"] = plan.ComponentId,
                    ["instance_name"] = plan.InstanceName,
                    ["display_name"] = plan.DisplayName,
                    ["function_profile"] = CloneJsonNode(plan.AssemblyFunctionProfile) ?? new JsonObject(),
                    ["interface_face_refs"] = new JsonArray(refs.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).Select(x => JsonValue.Create(x) as JsonNode).ToArray()),
                    ["interface_faces"] = JsonSerializer.SerializeToNode(interfaceFaces, JsonOptions.Default),
                    ["interface_image_refs"] = JsonSerializer.SerializeToNode(interfaceImages, JsonOptions.Default)
                });
            }

            var interfaceSubset = new JsonObject
            {
                ["schema"] = "functional_group_interface_subset_v1",
                ["group_id"] = groupId,
                ["selection_policy"] = "Only faces and image refs related to explicit mates or B-rep-derived contacts/fits in this functional group are included.",
                ["source_group"] = CloneJsonNode(group),
                ["member_interface_evidence"] = memberInterfaces
            };
            File.WriteAllText(interfaceSubsetPath, interfaceSubset.ToJsonString(JsonOptions.Default), new UTF8Encoding(false));

            var groupPartBrep = BuildFunctionalGroupPartBrepSubset(groupId, group, memberPlans);
            File.WriteAllText(groupPartBrepPath, groupPartBrep.ToJsonString(JsonOptions.Default), new UTF8Encoding(false));

            var fullRefs = new JsonObject
            {
                ["schema"] = "functional_group_part_full_evidence_refs_v1",
                ["group_id"] = groupId,
                ["separation_policy"] = "Full part evidence is kept separate from relation-scoped interface evidence. Use it only for later part-style checks inside the group.",
                ["group_part_brep_path"] = Path.GetRelativePath(outputDir, groupPartBrepPath).Replace('\\', '/'),
                ["all_face_highlight_directory"] = Path.GetRelativePath(outputDir, Path.Combine(groupDir, "all_face_highlights")).Replace('\\', '/'),
                ["member_parts"] = JsonSerializer.SerializeToNode(memberPlans.Select(plan => new
                {
                    plan.ComponentId,
                    plan.InstanceName,
                    plan.DisplayName,
                    component_path = ReadString(plan.Package, "Path"),
                    all_feature_count = plan.Features.Count,
                    all_face_count = plan.Faces.Count,
                    part_check_status = ReadString(plan.PartCheckEvidence, "status"),
                    part_check_evidence = plan.PartCheckEvidence
                }).ToList(), JsonOptions.Default)
            };
            File.WriteAllText(partFullRefsPath, fullRefs.ToJsonString(JsonOptions.Default), new UTF8Encoding(false));
            File.WriteAllText(markdownPath, BuildFunctionalGroupEvidenceMarkdown(groupId, memberPlans, group), new UTF8Encoding(false));

            imagePlanGroups.Add(BuildFunctionalGroupAllFaceHighlightPlanGroup(groupId, groupRelativeDir, memberPlans));

            manifestGroups.Add(new JsonObject
            {
                ["group_id"] = groupId,
                ["directory"] = Path.GetRelativePath(outputDir, groupDir).Replace('\\', '/'),
                ["interface_subset_path"] = Path.GetRelativePath(outputDir, interfaceSubsetPath).Replace('\\', '/'),
                ["part_full_evidence_refs_path"] = Path.GetRelativePath(outputDir, partFullRefsPath).Replace('\\', '/'),
                ["group_part_brep_path"] = Path.GetRelativePath(outputDir, groupPartBrepPath).Replace('\\', '/'),
                ["all_face_highlight_directory"] = Path.GetRelativePath(outputDir, Path.Combine(groupDir, "all_face_highlights")).Replace('\\', '/'),
                ["member_component_ids"] = new JsonArray(memberIds.Select(x => JsonValue.Create(x) as JsonNode).ToArray())
            });
        }

        var imagePlan = new JsonObject
        {
            ["schema"] = "functional_group_all_face_highlight_export_plan_v1",
            ["generation_stage"] = "after_functional_group_split_before_group_brep_subset_use",
            ["brep_policy"] = "No B-rep extraction is requested by this plan. Face refs come from the initial assembly B-rep extraction; this step only opens part files to select faces and save highlight images.",
            ["report_file_name"] = Path.GetFileName(imageExportManifestPath),
            ["groups"] = imagePlanGroups
        };
        File.WriteAllText(imagePlanPath, imagePlan.ToJsonString(JsonOptions.Default), new UTF8Encoding(false));

        var imageExportStep = imagePlanGroups.Count > 0
            ? RunProjectWithCaptureLocal(root, sectionBrepExtractorProject, [
                "--face-highlight-plan",
                imagePlanPath,
                "--output-dir",
                rootDir
            ])
            : new StepResult
            {
                Project = sectionBrepExtractorProject,
                ExitCode = 0,
                StandardOutput = "No functional-group face highlight images requested.",
                StandardError = ""
            };

        var manifest = new JsonObject
        {
            ["schema"] = "functional_group_evidence_manifest_v1",
            ["root_directory"] = Path.GetRelativePath(outputDir, rootDir).Replace('\\', '/'),
            ["evidence_separation_policy"] = "interface_subset contains only contact/mate/interface faces and related image refs; group_part_brep contains full initial-extraction B-rep evidence split by functional group; all_face_highlights contains every face highlight image for later part-style checks inside the group.",
            ["all_face_highlight_export_plan_path"] = Path.GetRelativePath(outputDir, imagePlanPath).Replace('\\', '/'),
            ["all_face_highlight_export_manifest_path"] = Path.GetRelativePath(outputDir, imageExportManifestPath).Replace('\\', '/'),
            ["all_face_highlight_export_status"] = new JsonObject
            {
                ["project"] = imageExportStep.Project,
                ["exit_code"] = imageExportStep.ExitCode,
                ["output"] = SummarizeLocal(imageExportStep.StandardOutput),
                ["error"] = SummarizeLocal(imageExportStep.StandardError)
            },
            ["groups"] = manifestGroups
        };
        File.WriteAllText(manifestPath, manifest.ToJsonString(JsonOptions.Default), new UTF8Encoding(false));
        AttachFunctionalGroupAllFaceHighlightImages(outputDir, plans);
        return manifestPath;
    }

    static void AttachFunctionalGroupAllFaceHighlightImages(string outputDir, IReadOnlyList<ComponentPromptPlan> plans)
    {
        var rootDir = Path.Combine(outputDir, "functional_group_evidence");
        if (!Directory.Exists(rootDir))
            return;

        foreach (var plan in plans)
        {
            var componentDirName = SanitizeFileName(plan.ComponentId);
            var files = Directory.EnumerateFiles(rootDir, "*.jpg", SearchOption.AllDirectories)
                .Where(path => path.Replace('\\', '/').Contains($"/all_face_highlights/{componentDirName}/", StringComparison.OrdinalIgnoreCase))
                .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
                .ToList();
            var replacementImages = new List<JsonElement>();
            foreach (var file in files)
            {
                var faceIndex = ParseFaceIndexFromFileName(Path.GetFileNameWithoutExtension(file));
                var faceRef = faceIndex > 0 ? $"{plan.InstanceName}:face#{faceIndex}" : "";
                var imageRef = JsonSerializer.SerializeToElement(new
                {
                    evidence_name = Path.GetRelativePath(outputDir, file).Replace('\\', '/'),
                    image_kind = "functional_group_all_face_highlight",
                    evidence_family = "part_all_face_surface",
                    evidence_purpose = "post_functional_group_part_and_surface_diagnosis",
                    position_visibility = "not_applicable",
                    view_name = "isometric",
                    requested_view = "isometric",
                    highlight_plan_id = $"functional_group_all_face_{plan.ComponentId}_{faceIndex:0000}",
                    highlight_component_id = plan.ComponentId,
                    highlight_feature_id = faceIndex > 0 ? $"face_{faceIndex:0000}" : "",
                    highlight_feature_type = "single_face",
                    highlight_face_refs = string.IsNullOrWhiteSpace(faceRef) ? Array.Empty<string>() : new[] { faceRef },
                    highlight_view_direction_mm = new[] { 1.0, 1.0, 1.0 },
                    highlight_blocking_face_refs = Array.Empty<string>(),
                    evidence_source = "functional_group_all_face_highlights_after_group_split",
                    output_path = file
                }, JsonOptions.Default);
                replacementImages.Add(imageRef);
            }
            plan.AvailableImages = replacementImages;
        }
    }

    static int ParseFaceIndexFromFileName(string value)
    {
        var marker = value.LastIndexOf("face_", StringComparison.OrdinalIgnoreCase);
        if (marker < 0)
            return 0;
        var digits = new string(value.Skip(marker + "face_".Length).TakeWhile(char.IsDigit).ToArray());
        return int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) ? index : 0;
    }

    static List<JsonObject> UniqueFunctionalGroups(IReadOnlyList<ComponentPromptPlan> plans)
    {
        var result = new List<JsonObject>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var plan in plans)
        {
            if (plan.FunctionalGroupContext["groups"] is not JsonArray groups)
                continue;
            foreach (var group in groups.OfType<JsonObject>())
            {
                var id = NodeString(group, "group_id");
                if (!string.IsNullOrWhiteSpace(id) && seen.Add(id))
                    result.Add(CloneJsonNode(group) as JsonObject ?? group);
            }
        }
        return result.OrderBy(group => NodeString(group, "group_id"), StringComparer.OrdinalIgnoreCase).ToList();
    }

    static JsonObject BuildFunctionalGroupPartBrepSubset(string groupId, JsonObject group, IReadOnlyList<ComponentPromptPlan> memberPlans)
    {
        var memberParts = new JsonArray();
        foreach (var plan in memberPlans.OrderBy(plan => plan.ComponentId, StringComparer.OrdinalIgnoreCase))
        {
            memberParts.Add(new JsonObject
            {
                ["component_id"] = plan.ComponentId,
                ["instance_name"] = plan.InstanceName,
                ["display_name"] = plan.DisplayName,
                ["component_path"] = ReadString(plan.Package, "Path"),
                ["brep_source"] = ReadString(plan.Package, "BrepSource"),
                ["reuse_policy"] = "B-rep faces/features are copied from the initial assembly SectionBrepExtractor report; this file is a functional-group split, not a new extraction.",
                ["function_profile"] = CloneJsonNode(plan.AssemblyFunctionProfile) ?? new JsonObject(),
                ["features"] = JsonSerializer.SerializeToNode(plan.Features.Select(item => item.Clone()).ToList(), JsonOptions.Default),
                ["faces"] = JsonSerializer.SerializeToNode(plan.Faces.Select(item => item.Clone()).ToList(), JsonOptions.Default),
                ["direct_contact_relations"] = JsonSerializer.SerializeToNode(plan.Contacts.Select(item => item.Clone()).ToList(), JsonOptions.Default),
                ["direct_mate_relations"] = JsonSerializer.SerializeToNode(plan.Mates.Select(item => item.Clone()).ToList(), JsonOptions.Default)
            });
        }

        return new JsonObject
        {
            ["schema"] = "functional_group_part_brep_subset_v1",
            ["group_id"] = groupId,
            ["generation_stage"] = "after_functional_group_split",
            ["usage_policy"] = "Use this full group-split B-rep evidence for later part-style checks inside the functional group. Functional-group description should still use interface_subset.json instead of all faces.",
            ["source_group"] = CloneJsonNode(group),
            ["member_parts"] = memberParts
        };
    }

    static JsonObject BuildFunctionalGroupAllFaceHighlightPlanGroup(string groupId, string groupRelativeDir, IReadOnlyList<ComponentPromptPlan> memberPlans)
    {
        var components = new JsonArray();
        foreach (var plan in memberPlans.OrderBy(plan => plan.ComponentId, StringComparer.OrdinalIgnoreCase))
        {
            var componentPath = FirstNonEmpty(ReadString(plan.Package, "Path"), ReadString(plan.Component, "Path"));
            var faces = new JsonArray();
            foreach (var face in plan.Faces.OrderBy(face => ReadInt(face, "FaceIndex")))
            {
                var faceRef = FaceEvidenceRef(face);
                var faceIndex = ReadInt(face, "FaceIndex");
                if (string.IsNullOrWhiteSpace(faceRef) || faceIndex <= 0)
                    continue;

                var faceKey = $"face_{faceIndex:0000}";
                faces.Add(new JsonObject
                {
                    ["plan_id"] = $"{SanitizeFileName(groupId)}_{SanitizeFileName(plan.ComponentId)}_{faceKey}",
                    ["face_ref"] = faceRef,
                    ["face_index"] = faceIndex,
                    ["face_kind"] = ReadString(face, "FaceKind"),
                    ["feature_id"] = faceKey,
                    ["views"] = new JsonArray(JsonValue.Create("isometric")),
                    ["preferred_direction_mm"] = new JsonArray(JsonValue.Create(1.0), JsonValue.Create(1.0), JsonValue.Create(1.0)),
                    ["output_relative_directory"] = Path.Combine(groupRelativeDir, "all_face_highlights", SanitizeFileName(plan.ComponentId)).Replace('\\', '/'),
                    ["output_file_stem"] = faceKey
                });
            }

            components.Add(new JsonObject
            {
                ["component_id"] = plan.ComponentId,
                ["instance_name"] = plan.InstanceName,
                ["display_name"] = plan.DisplayName,
                ["component_path"] = componentPath,
                ["face_count"] = plan.Faces.Count,
                ["faces"] = faces
            });
        }

        return new JsonObject
        {
            ["group_id"] = groupId,
            ["directory"] = groupRelativeDir,
            ["components"] = components
        };
    }

    static List<string> FunctionalGroupMemberIds(JsonObject group)
    {
        var ids = new List<string>();
        if (group["member_components"] is not JsonArray members)
            return ids;
        foreach (var member in members.OfType<JsonObject>())
        {
            var id = NodeString(member, "component_id");
            if (!string.IsNullOrWhiteSpace(id))
                ids.Add(id);
        }
        return ids.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToList();
    }

    static Dictionary<string, HashSet<string>> InterfaceFaceRefsByComponent(JsonObject group)
    {
        var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
        if (group["interfaces"] is not JsonArray interfaces)
            return result;
        foreach (var relation in interfaces.OfType<JsonObject>())
        {
            Add(NodeString(relation, "component_a_id"), NodeString(relation, "face_a_ref"));
            Add(NodeString(relation, "component_b_id"), NodeString(relation, "face_b_ref"));
        }
        return result;

        void Add(string componentId, string faceRef)
        {
            if (string.IsNullOrWhiteSpace(componentId) || string.IsNullOrWhiteSpace(faceRef))
                return;
            if (!result.TryGetValue(componentId, out var refs))
            {
                refs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                result[componentId] = refs;
            }
            refs.Add(faceRef);
        }
    }

    static IEnumerable<JsonElement> FilterImagesByFaceRefs(IEnumerable<JsonElement> images, IReadOnlySet<string> faceRefs)
    {
        foreach (var image in images)
        {
            var family = ReadString(image, "evidence_family");
            var kind = ReadString(image, "image_kind");
            var isPhysicalInterface = family.Equals("assembly_physical_interface", StringComparison.OrdinalIgnoreCase) ||
                string.IsNullOrWhiteSpace(family) &&
                (kind.Equals("assembly_physical_interface_view", StringComparison.OrdinalIgnoreCase) ||
                 kind.Equals("assembly_interface_face_pair_highlight_view", StringComparison.OrdinalIgnoreCase));
            var verificationStatus = ReadString(image, "interface_verification_status");
            if (!isPhysicalInterface ||
                !ReadBoolean(image, "trimmed_patch_verified") ||
                !verificationStatus.Equals("trimmed_interface_confirmed", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var refs = ReadStringArrayValue(image, "highlight_face_refs")
                .ToHashSet(StringComparer.OrdinalIgnoreCase);
            if (refs.Count > 0 && refs.Overlaps(faceRefs))
                yield return image;
        }
    }

    static string BuildFunctionalGroupEvidenceMarkdown(string groupId, IReadOnlyList<ComponentPromptPlan> memberPlans, JsonObject group)
    {
        var sb = new StringBuilder();
        sb.AppendLine($"# {groupId} 功能组证据");
        sb.AppendLine();
        sb.AppendLine("## 接口子集");
        sb.AppendLine("只包含配合/接触/同轴/贴合/近零间隙等功能组判断相关面。");
        sb.AppendLine();
        sb.AppendLine("## 组内零件");
        foreach (var plan in memberPlans)
            sb.AppendLine($"- {plan.ComponentId} {plan.DisplayName}: {ComponentProfileString(plan, "function_introduction")}");
        sb.AppendLine();
        sb.AppendLine("## 关键接口");
        if (group["interfaces"] is JsonArray interfaces)
        {
            foreach (var item in interfaces.OfType<JsonObject>())
                sb.AppendLine($"- {NodeString(item, "relation_id")}: {NodeString(item, "component_a_name")} `{NodeString(item, "face_a_ref")}` <-> {NodeString(item, "component_b_name")} `{NodeString(item, "face_b_ref")}`");
        }
        return sb.ToString();
    }

    static async Task GenerateInterfaceAnchorAnalysisAsync(
        string model,
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        DiagnosticContextArtifacts diagnosticContext,
        HttpClient http,
        string baseUrl,
        string apiMode,
        int maxImages)
    {
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);
        var jsonPath = Path.Combine(contextDir, "interface_anchor_analysis.json");
        var promptPath = Path.Combine(contextDir, "interface_anchor_analysis_prompt.md");
        var rawPath = Path.Combine(contextDir, "interface_anchor_analysis_response.json");
        diagnosticContext.InterfaceAnchorAnalysisPath = jsonPath;

        var anchorImages = CollectPhysicalInterfaceEvidence(outputDir, highConfidenceOnly: true);
        if (anchorImages.Count == 0 || plans.All(plan => plan.IsPartDocument))
        {
            WriteJsonFile(jsonPath, new
            {
                schema = "interface_anchor_analysis_v1",
                generation_mode = "disabled_or_no_high_confidence_physical_interfaces",
                propagation_policy = "No interface conclusions are eligible for propagation.",
                interface_conclusions = Array.Empty<object>()
            });
            return;
        }

        var payload = new
        {
            schema = "interface_anchor_analysis_input_v1",
            evidence_policy = "Only evidence_family=assembly_physical_interface with confidence tier anchor/high is included. Component-position and all-face images are excluded.",
            product_description = plans.Select(plan => plan.ProductDescription).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "",
            component_function_profiles = plans.Select(plan => plan.AssemblyFunctionProfile).Where(profile => profile != null).ToList(),
            interface_images = anchorImages
        };
        var prompt = $$"""
You are a mechanical physical-interface function analyst. Answer in Chinese.

Analyze only the high-confidence anchor interfaces in the input and attached images.
- Attached images are exclusively evidence_family=assembly_physical_interface. They show two components at their actual assembly position with the physical interface faces highlighted.
- Never treat display separation, highlight thickness, rendering artifacts, or nominally coincident CAD faces as measured clearance or interference.
- B-rep metrics are auxiliary identity and geometry checks. Infer interface function primarily from the interface images, component identities, surrounding structures, mates, and already established component function profiles.
- Determine whether the interface is a through/clearance passage, sliding or guiding fit, centering register, bearing-ring seat, fixed mounting fit, sealing interface, torque-transfer interface, axial locating interface, or another evidence-supported class.
- Do not choose an exact tolerance grade. fit_intent may only be clearance_tendency, transition_tendency, interference_tendency, non_fit_clearance, or unresolved.
- Only conclusions with confidence >= 0.80 and direct image plus relation evidence may set propagation_eligible=true.
- Candidate or ambiguous conclusions must not be propagated.
- Return exactly one fenced JSON object and no prose outside it.

Return schema:
```json
{
  "schema": "interface_anchor_analysis_v1",
  "generation_mode": "high_confidence_physical_interfaces_first",
  "propagation_policy": "Only propagation_eligible conclusions may be used as established context in the next interface-analysis round.",
  "interface_conclusions": [
    {
      "physical_interface_id": "physical_interface_x",
      "source_contact_ids": [],
      "component_pair": [],
      "interface_class": "bearing_inner_ring_seat|bearing_outer_ring_seat|through_clearance|sliding_guide|centering_register|fixed_mounting_fit|sealing|torque_transfer|axial_location|other|unresolved",
      "interface_function": "evidence-grounded Chinese description",
      "relative_motion": "locked|rotating|sliding|assembly_only|unknown",
      "load_or_motion_role": [],
      "fit_intent": "clearance_tendency|transition_tendency|interference_tendency|non_fit_clearance|unresolved",
      "confidence": 0.0,
      "confidence_tier": "confirmed|likely|candidate|uncertain",
      "propagation_eligible": false,
      "evidence_refs": [],
      "image_evidence_names": [],
      "alternatives": [],
      "missing_conditions": []
    }
  ]
}
```

Input evidence:
```json
{{JsonSerializer.Serialize(payload, JsonOptions.Default)}}
```
""";
        File.WriteAllText(promptPath, prompt, new UTF8Encoding(false));

        try
        {
            var imagePaths = CollectPhysicalInterfaceImagePaths(anchorImages, maxImages);
            object request = apiMode == "responses"
                ? BuildResponsesRequest(model, prompt, imagePaths)
                : BuildChatCompletionsRequest(model, prompt, imagePaths);
            string endpoint = apiMode == "responses" ? $"{baseUrl}/responses" : $"{baseUrl}/chat/completions";
            using var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions.Default), Encoding.UTF8, "application/json");
            using var response = await http.PostAsync(endpoint, content);
            var raw = await response.Content.ReadAsStringAsync();
            File.WriteAllText(rawPath, raw, new UTF8Encoding(false));
            if (!response.IsSuccessStatusCode)
                throw new InvalidOperationException($"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}");

            var text = ExtractResponseText(raw);
            var json = ExtractJsonObjectWithSchema(text, "interface_anchor_analysis_v1");
            if (string.IsNullOrWhiteSpace(json))
                throw new InvalidOperationException("interface_anchor_analysis_v1 JSON was not found in AI response.");
            File.WriteAllText(jsonPath, NormalizeInterfaceAnchorAnalysisJson(json), new UTF8Encoding(false));
        }
        catch (Exception ex)
        {
            WriteJsonFile(jsonPath, new
            {
                schema = "interface_anchor_analysis_v1",
                generation_mode = "fallback_after_anchor_analysis_failure",
                message = ex.Message,
                propagation_policy = "AI anchor analysis failed; no conclusion is eligible for propagation.",
                interface_conclusions = Array.Empty<object>()
            });
        }
    }

    static string NormalizeInterfaceAnchorAnalysisJson(string json)
    {
        var root = JsonNode.Parse(json) as JsonObject ?? new JsonObject();
        root["schema"] = "interface_anchor_analysis_v1";
        root["propagation_policy"] = "Only conclusions with confidence >= 0.80, propagation_eligible=true, and direct physical-interface image evidence may be treated as established context.";
        root["evidence_family"] = "assembly_physical_interface";
        if (root["interface_conclusions"] is JsonArray conclusions)
        {
            foreach (var conclusion in conclusions.OfType<JsonObject>())
            {
                var confidence = NodeDouble(conclusion, "confidence");
                var hasImages = conclusion["image_evidence_names"] is JsonArray images && images.Count > 0;
                conclusion["propagation_eligible"] = confidence >= 0.80 && hasImages && NodeBool(conclusion, "propagation_eligible");
                conclusion["depends_on"] ??= new JsonArray();
                conclusion["alternatives"] ??= new JsonArray();
                conclusion["missing_conditions"] ??= new JsonArray();
            }
        }
        else
        {
            root["interface_conclusions"] = new JsonArray();
        }
        return root.ToJsonString(JsonOptions.Default);
    }

    static double NodeDouble(JsonObject node, string property)
    {
        if (node[property] is not JsonValue value)
            return 0;
        if (value.TryGetValue<double>(out var number))
            return number;
        return value.TryGetValue<string>(out var text) &&
               double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
            ? parsed
            : 0;
    }

    static bool NodeBool(JsonObject node, string property) =>
        node[property] is JsonValue value && value.TryGetValue<bool>(out var result) && result;

    static List<JsonObject> CollectPhysicalInterfaceEvidence(string outputDir, bool highConfidenceOnly)
    {
        var root = Path.Combine(outputDir, "functional_group_evidence");
        if (!Directory.Exists(root))
            return [];

        var result = new List<JsonObject>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var path in Directory.EnumerateFiles(root, "interface_subset.json", SearchOption.AllDirectories))
        {
            try
            {
                var subset = JsonNode.Parse(File.ReadAllText(path)) as JsonObject;
                if (subset?["member_interface_evidence"] is not JsonArray members)
                    continue;
                foreach (var member in members.OfType<JsonObject>())
                {
                    if (member["interface_image_refs"] is not JsonArray images)
                        continue;
                    foreach (var image in images.OfType<JsonObject>())
                    {
                        var family = NodeString(image, "evidence_family");
                        var kind = NodeString(image, "image_kind");
                        var isPhysicalInterface = family.Equals("assembly_physical_interface", StringComparison.OrdinalIgnoreCase) ||
                            string.IsNullOrWhiteSpace(family) &&
                            (kind.Equals("assembly_physical_interface_view", StringComparison.OrdinalIgnoreCase) ||
                             kind.Equals("assembly_interface_face_pair_highlight_view", StringComparison.OrdinalIgnoreCase));
                        if (!isPhysicalInterface ||
                            !NodeBool(image, "trimmed_patch_verified") ||
                            !NodeString(image, "interface_verification_status").Equals("trimmed_interface_confirmed", StringComparison.OrdinalIgnoreCase))
                        {
                            continue;
                        }

                        var tier = NodeString(image, "interface_confidence_tier");
                        if (highConfidenceOnly && tier is not ("anchor" or "high"))
                            continue;
                        var key = FirstNonEmpty(NodeString(image, "evidence_name"), NodeString(image, "output_path"));
                        if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
                            continue;
                        result.Add(CloneJsonNode(image) as JsonObject ?? new JsonObject());
                    }
                }
            }
            catch
            {
                // One malformed evidence subset must not mix or suppress the remaining groups.
            }
        }

        return result
            .OrderByDescending(image => NodeDouble(image, "interface_analysis_priority"))
            .ThenBy(image => NodeString(image, "physical_interface_id"), StringComparer.OrdinalIgnoreCase)
            .ThenBy(image => NodeString(image, "requested_view"), StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    static List<string> CollectPhysicalInterfaceImagePaths(IReadOnlyList<JsonObject> images, int maxImages)
    {
        if (maxImages <= 0)
            return [];

        var ordered = images
            .Where(image => !string.IsNullOrWhiteSpace(NodeString(image, "output_path")) && File.Exists(NodeString(image, "output_path")))
            .GroupBy(image => FirstNonEmpty(NodeString(image, "physical_interface_id"), NodeString(image, "highlight_plan_id")), StringComparer.OrdinalIgnoreCase)
            .OrderByDescending(group => group.Max(image => NodeDouble(image, "interface_analysis_priority")))
            .ToList();
        var selected = new List<JsonObject>();
        foreach (var preferredView in new[] { "assembled_oblique", "axis_end", "normal_view", "axis_side" })
        {
            foreach (var group in ordered)
            {
                var candidate = group.FirstOrDefault(image =>
                    NodeString(image, "requested_view").Equals(preferredView, StringComparison.OrdinalIgnoreCase) &&
                    selected.All(existing => !ReferenceEquals(existing, image)));
                if (candidate != null)
                    selected.Add(candidate);
                if (selected.Count >= maxImages)
                    break;
            }
            if (selected.Count >= maxImages)
                break;
        }

        if (selected.Count < maxImages)
        {
            selected.AddRange(images.Where(image => selected.All(existing => !ReferenceEquals(existing, image)))
                .Take(maxImages - selected.Count));
        }
        return selected
            .Select(image => NodeString(image, "output_path"))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Take(maxImages)
            .ToList();
    }

    static async Task GenerateFunctionalGroupDescriptionsAsync(
        string model,
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        DiagnosticContextArtifacts diagnosticContext,
        HttpClient http,
        string baseUrl,
        string apiMode,
        int maxImages)
    {
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);
        var jsonPath = Path.Combine(contextDir, "functional_group_descriptions.json");
        var markdownPath = Path.Combine(contextDir, "functional_group_descriptions.md");
        var promptPath = Path.Combine(contextDir, "functional_group_descriptions_prompt.md");
        var rawPath = Path.Combine(contextDir, "functional_group_descriptions_response.json");
        diagnosticContext.FunctionalGroupDescriptionsPath = jsonPath;
        diagnosticContext.FunctionalGroupDescriptionsMarkdownPath = markdownPath;

        var groups = UniqueFunctionalGroups(plans);
        if (groups.Count == 0 || plans.All(plan => plan.IsPartDocument))
        {
            var fallback = BuildFallbackFunctionalGroupDescriptions(groups, "disabled_or_no_groups", "No assembly functional groups were available.");
            WriteJsonFile(jsonPath, fallback);
            File.WriteAllText(markdownPath, BuildFunctionalGroupDescriptionsMarkdown(fallback), new UTF8Encoding(false));
            AssignFunctionalGroupDescriptions(plans, fallback);
            return;
        }

        var prompt = BuildFunctionalGroupDescriptionPrompt(outputDir, plans, groups, diagnosticContext);
        File.WriteAllText(promptPath, prompt, new UTF8Encoding(false));

        try
        {
            var interfaceImages = CollectPhysicalInterfaceEvidence(outputDir, highConfidenceOnly: false);
            var interfaceImagePaths = CollectPhysicalInterfaceImagePaths(interfaceImages, maxImages);
            object request = apiMode == "responses"
                ? BuildResponsesRequest(model, prompt, interfaceImagePaths)
                : BuildChatCompletionsRequest(model, prompt, interfaceImagePaths);
            string endpoint = apiMode == "responses" ? $"{baseUrl}/responses" : $"{baseUrl}/chat/completions";

            using var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions.Default), Encoding.UTF8, "application/json");
            using var response = await http.PostAsync(endpoint, content);
            var raw = await response.Content.ReadAsStringAsync();
            File.WriteAllText(rawPath, raw, new UTF8Encoding(false));
            if (!response.IsSuccessStatusCode)
                throw new InvalidOperationException($"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}");

            var text = ExtractResponseText(raw);
            var json = ExtractJsonObjectWithSchema(text, "functional_group_descriptions_v1");
            if (string.IsNullOrWhiteSpace(json))
                throw new InvalidOperationException("functional_group_descriptions_v1 JSON was not found in AI response.");

            File.WriteAllText(jsonPath, NormalizeFunctionalGroupDescriptionsJson(json), new UTF8Encoding(false));
            using var doc = JsonDocument.Parse(File.ReadAllText(jsonPath));
            var root = doc.RootElement.Clone();
            File.WriteAllText(markdownPath, BuildFunctionalGroupDescriptionsMarkdown(root), new UTF8Encoding(false));
            AssignFunctionalGroupDescriptions(plans, root);
            AssignFunctionalGroupInspectionCards(plans, maxCards: 5);
        }
        catch (Exception ex)
        {
            var fallback = BuildFallbackFunctionalGroupDescriptions(groups, "diagnostic_context_fallback", ex.Message);
            WriteJsonFile(jsonPath, fallback);
            File.WriteAllText(markdownPath, BuildFunctionalGroupDescriptionsMarkdown(fallback), new UTF8Encoding(false));
            AssignFunctionalGroupDescriptions(plans, fallback);
            AssignFunctionalGroupInspectionCards(plans, maxCards: 5);
        }
    }

    static string BuildFunctionalGroupDescriptionPrompt(
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        IReadOnlyList<JsonObject> groups,
        DiagnosticContextArtifacts diagnosticContext)
    {
        var payload = new
        {
            schema = "functional_group_description_input_v1",
            task = "Generate detailed functional descriptions for each functional group before design-fault judgement.",
            evidence_files = new
            {
                component_function_introductions = diagnosticContext.ComponentFunctionIntroductionsPath,
                functional_group_relationships = diagnosticContext.FunctionalGroupRelationshipsPath,
                interface_anchor_analysis = diagnosticContext.InterfaceAnchorAnalysisPath,
                functional_group_static_scout_rules = diagnosticContext.FunctionalGroupStaticScoutRulesPath
            },
            evidence_boundary = "This second interface-analysis round uses component function profiles, propagation-eligible anchor conclusions, interface/contact/mate B-rep summaries, and evidence_family=assembly_physical_interface images only. Component-position and all-face images are excluded.",
            interface_anchor_analysis = File.Exists(diagnosticContext.InterfaceAnchorAnalysisPath)
                ? JsonNode.Parse(File.ReadAllText(diagnosticContext.InterfaceAnchorAnalysisPath))
                : null,
            functional_group_static_scout_rules = File.Exists(diagnosticContext.FunctionalGroupStaticScoutRulesPath)
                ? File.ReadAllText(diagnosticContext.FunctionalGroupStaticScoutRulesPath)
                : "",
            groups = groups.Select(group =>
            {
                var groupId = NodeString(group, "group_id");
                var interfaceSubsetPath = Path.Combine(outputDir, "functional_group_evidence", SanitizeFileName(groupId), "interface_subset.json");
                var groupPartBrepPath = Path.Combine(outputDir, "functional_group_evidence", SanitizeFileName(groupId), "group_part_brep.json");
                var interfaceSubset = File.Exists(interfaceSubsetPath)
                    ? JsonNode.Parse(File.ReadAllText(interfaceSubsetPath))
                    : null;
                return new
                {
                    group_id = groupId,
                    interface_subset_path = interfaceSubsetPath,
                    group_part_brep_path = groupPartBrepPath,
                    group_part_brep_usage = "Only use relation-scoped faces/features already present in interface_subset for this description. The full B-rep path is recorded for traceability and later part-level checks; do not load all faces or all face highlight images here.",
                    component_function_profiles = FunctionalGroupMemberIds(group)
                        .Select(id => plans.FirstOrDefault(plan => plan.ComponentId.Equals(id, StringComparison.OrdinalIgnoreCase)))
                        .Where(plan => plan != null)
                        .Select(plan => new
                        {
                            plan!.ComponentId,
                            plan.InstanceName,
                            plan.DisplayName,
                            function_profile = plan.AssemblyFunctionProfile
                        })
                        .ToList(),
                    interface_subset = interfaceSubset
                };
            }).ToList()
        };
        var json = JsonSerializer.Serialize(payload, JsonOptions.Default);
        return $$"""
You are a mechanical assembly function analyst. Answer in Chinese.

Task:
- For each functional group, use only the member function profiles and the relation-scoped interface evidence.
- This is the second interface-analysis round. First read interface_anchor_analysis. Only items with propagation_eligible=true may be treated as established context. All other anchor outputs remain candidates and must not influence unrelated interfaces.
- Attached images are exclusively evidence_family=assembly_physical_interface. Never use component-position images or functional_group_all_face_highlight images in this stage.
- Analyze remaining interfaces in descending evidence confidence. For each conclusion, record depends_on with the exact propagated anchor interface ids actually used.
- Do not infer measured clearance or interference from nominal CAD coincidence or rendering. Infer only functional fit intent, and keep alternatives/missing conditions when evidence is incomplete.
- Describe how the group achieves its function in an evidence-grounded way: which parts participate, which exact face pairs or mate relations provide locating/support/fit/transmission/sealing/guiding/fastening, and what should be checked later.
- For every critical interface, state relation_id, evidence_class, relation_source, participating components, face_pair, mechanical role, function_realization, and judgement_relevance.
- Distinguish explicit SolidWorks mates from B-rep-derived contacts, planar coincident/flush contacts, coaxial cylindrical fits, and near-zero-gap spatial contacts.
- Use the assembly/function-group static scout rules to make the description retrieval-oriented for assembly-interface and functional-group risks.
- Do not use full part evidence for this group-level description except as a file reference for later part-level checks.
- Do not load or reference all_face_highlights, all_face_highlight_export_manifest, or images of every face. They are reserved for the later part-level checking stage.
- Do not judge final design errors here. Produce detailed functional descriptions for downstream judgement.
- Treat B-rep-derived contacts/coincident faces/coaxial fits/near-zero-gap candidates as valid functional interfaces even without explicit SolidWorks mates.
- Return exactly one fenced JSON object. No prose outside the JSON block.

Return schema:
```json
{
  "schema": "functional_group_descriptions_v1",
  "generation_mode": "ai_from_component_functions_and_interface_subset",
  "diagnostic_stage": "{{DiagnosticFlowTerms.StageFunctionalGroupCheck}}",
  "knowledge_scope": "{{DiagnosticFlowTerms.KnowledgeScopeAssembly}}",
  "groups": [
    {
      "group_id": "functional_group_001",
      "diagnostic_stage": "{{DiagnosticFlowTerms.StageFunctionalGroupCheck}}",
      "knowledge_scope": "{{DiagnosticFlowTerms.KnowledgeScopeAssembly}}",
      "function_summary": "该功能组整体实现什么功能",
      "member_component_functions": [
        { "component_id": "component_1", "function_in_group": "该零件在功能组中的作用" }
      ],
      "working_principle": "零件之间通过哪些接触/配合/同轴/贴合关系实现功能",
      "critical_interfaces": [
        {
          "relation_id": "contact_x",
          "evidence_class": "explicit_mate|brep_planar_contact|brep_coaxial_fit|brep_near_zero_gap_contact|brep_spatial_contact_candidate",
          "relation_source": "solidworks_mate|brep_geometry_contact",
          "component_pair": ["component_a", "component_b"],
          "interface_function": "定位/支撑/导向/密封/传力等",
          "interface_class": "through_clearance|sliding_guide|centering_register|bearing_inner_ring_seat|bearing_outer_ring_seat|fixed_mounting_fit|sealing|torque_transfer|axial_location|other|unresolved",
          "relative_motion": "locked|rotating|sliding|assembly_only|unknown",
          "fit_intent": "clearance_tendency|transition_tendency|interference_tendency|non_fit_clearance|unresolved",
          "face_pair": "human-readable face pair",
          "function_realization": "说明这个面-面关系如何实现功能组功能",
          "judgement_relevance": "说明为什么这个接口是功能组设计合理性判定关键",
          "evidence_refs": ["real relation ids or face refs"],
          "image_evidence_names": ["physical interface image names only"],
          "depends_on": ["propagation-eligible physical interface ids only"],
          "alternatives": [],
          "missing_conditions": [],
          "confidence": 0.0
        }
      ],
      "interface_realization_steps": ["按接口顺序说明该功能组如何从零件功能画像和面-面关系实现整体功能"],
      "group_level_judgement_focus": ["后续功能组设计判定需要关注的点"],
      "part_level_followup_scope": ["该组内后续需要做零件式查错的零件"],
      "evidence_refs": ["real group ids, component ids, relation ids, face refs, image names"],
      "confidence": "low|medium|high"
    }
  ]
}
```

Input evidence:
```json
{{json}}
```
""";
    }

    static string NormalizeFunctionalGroupDescriptionsJson(string json)
    {
        try
        {
            var root = JsonNode.Parse(json) as JsonObject;
            if (root == null)
                return NormalizeJson(json);

            root["schema"] = "functional_group_descriptions_v1";
            root["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck;
            root["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly;
            root["evidence_boundary"] = "component function profiles + propagation-eligible interface anchors + assembly_physical_interface images only; excludes component-position and all-face image families";

            if (root["groups"] is JsonArray groups)
            {
                foreach (var group in groups.OfType<JsonObject>())
                {
                    group["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck;
                    group["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly;
                    group["member_component_functions"] ??= new JsonArray();
                    group["critical_interfaces"] ??= new JsonArray();
                    group["interface_realization_steps"] ??= new JsonArray();
                    group["group_level_judgement_focus"] ??= new JsonArray();
                    group["part_level_followup_scope"] ??= new JsonArray();
                    group["evidence_refs"] ??= new JsonArray();

                    if (group["critical_interfaces"] is JsonArray interfaces)
                    {
                        foreach (var item in interfaces.OfType<JsonObject>())
                        {
                            item["interface_scope"] ??= "functional_group_relation_scoped";
                            item["evidence_class"] ??= FirstNonEmpty(NodeString(item, "relation_type"), "interface_relation");
                            item["function_realization"] ??= FirstNonEmpty(NodeString(item, "interface_function"), NodeString(item, "mechanical_role"));
                            item["judgement_relevance"] ??= "This interface is relation-scoped evidence for functional-group design judgement.";
                            item["image_evidence_names"] ??= new JsonArray();
                            item["depends_on"] ??= new JsonArray();
                            item["alternatives"] ??= new JsonArray();
                            item["missing_conditions"] ??= new JsonArray();
                        }
                    }
                }
            }

            return root.ToJsonString(JsonOptions.Default);
        }
        catch
        {
            return NormalizeJson(json);
        }
    }

    static JsonObject BuildFallbackFunctionalGroupDescriptions(IReadOnlyList<JsonObject> groups, string generationMode, string message)
    {
        var resultGroups = new JsonArray();
        foreach (var group in groups)
        {
            var groupId = NodeString(group, "group_id");
            resultGroups.Add(new JsonObject
            {
                ["group_id"] = groupId,
                ["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck,
                ["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly,
                ["function_summary"] = "该功能组由直接配合或 B-rep 几何接触关系连接，需要结合接口子集确认整体功能。",
                ["member_component_functions"] = CloneJsonNode(group["member_components"]) ?? new JsonArray(),
                ["working_principle"] = "通过功能组内的接触面、配合面、同轴或贴合候选关系实现定位、支撑、导向、传力或连接。",
                ["critical_interfaces"] = CloneJsonNode(group["interfaces"]) ?? new JsonArray(),
                ["interface_realization_steps"] = new JsonArray(JsonValue.Create("根据 interface_subset 中的关系类别、面-面配对和零件功能画像判断功能实现路径。")),
                ["group_level_judgement_focus"] = new JsonArray(JsonValue.Create("locating/support/fit/interface reasonableness")),
                ["part_level_followup_scope"] = CloneJsonNode(group["member_components"]) ?? new JsonArray(),
                ["evidence_refs"] = new JsonArray(JsonValue.Create(groupId)),
                ["confidence"] = "low"
            });
        }

        return new JsonObject
        {
            ["schema"] = "functional_group_descriptions_v1",
            ["generation_mode"] = generationMode,
            ["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck,
            ["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly,
            ["message"] = message,
            ["groups"] = resultGroups
        };
    }

    static void AssignFunctionalGroupDescriptions(IReadOnlyList<ComponentPromptPlan> plans, object rootValue)
    {
        var root = JsonSerializer.SerializeToNode(rootValue, JsonOptions.Default) as JsonObject;
        if (root?["groups"] is not JsonArray descriptions)
            return;
        var byGroupId = descriptions
            .OfType<JsonObject>()
            .Select(group => new { Id = NodeString(group, "group_id"), Group = group })
            .Where(item => !string.IsNullOrWhiteSpace(item.Id))
            .ToDictionary(item => item.Id, item => item.Group, StringComparer.OrdinalIgnoreCase);

        foreach (var plan in plans)
        {
            if (plan.FunctionalGroupContext["groups"] is not JsonArray groups)
                continue;
            foreach (var group in groups.OfType<JsonObject>())
            {
                var groupId = NodeString(group, "group_id");
                if (byGroupId.TryGetValue(groupId, out var description))
                    group["detailed_function_description"] = CloneJsonNode(description);
            }
        }
    }

    static void AssignFunctionalGroupInspectionCards(IReadOnlyList<ComponentPromptPlan> plans, int maxCards)
    {
        var cards = BuildInspectionCardsFromKnowledgeBase("chapter19_24_rule_store_v1.json", "chapter19_24_rule_index_v1.json", rule => RuleBelongsToChapter(rule, 19));
        if (cards.Count == 0)
            return;

        foreach (var plan in plans)
        {
            if (plan.FunctionalGroupContext["groups"] is not JsonArray groups)
                continue;
            foreach (var group in groups.OfType<JsonObject>())
            {
                var query = group.ToJsonString(JsonOptions.Default);
                var queryTokens = TokenizeInspectionText(query);
                var selected = cards
                    .Select(card => new
                    {
                        Card = card,
                        Score = TokenizeInspectionText(card.SearchText).Count(queryTokens.Contains)
                    })
                    .Where(item => item.Score > 0)
                    .OrderByDescending(item => item.Score)
                    .ThenBy(item => item.Card.RuleId, StringComparer.OrdinalIgnoreCase)
                    .Take(maxCards)
                    .Select(item => JsonSerializer.SerializeToNode(new
                    {
                        score = item.Score,
                        item.Card.Dto
                    }, JsonOptions.Default))
                    .Where(node => node != null)
                    .ToArray();
                group["matched_functional_group_inspection_cards"] = new JsonArray(selected);
            }
        }
    }

    static string BuildFunctionalGroupDescriptionsMarkdown(object rootValue)
    {
        var root = JsonSerializer.SerializeToNode(rootValue, JsonOptions.Default) as JsonObject;
        var sb = new StringBuilder();
        sb.AppendLine("# Functional Group Descriptions");
        sb.AppendLine();
        if (root?["groups"] is JsonArray groups)
        {
            foreach (var group in groups.OfType<JsonObject>())
            {
                sb.AppendLine($"## {NodeString(group, "group_id")}");
                sb.AppendLine();
                sb.AppendLine($"- Function: {NodeString(group, "function_summary")}");
                sb.AppendLine($"- Working principle: {NodeString(group, "working_principle")}");
                sb.AppendLine($"- Confidence: {NodeString(group, "confidence")}");
                sb.AppendLine();
            }
        }
        return sb.ToString();
    }

    static void ExportInterfaceFunctionDescriptions(
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        DiagnosticContextArtifacts diagnosticContext)
    {
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);
        var jsonPath = Path.Combine(contextDir, "interface_function_descriptions.json");
        var markdownPath = Path.Combine(contextDir, "interface_function_descriptions.md");
        diagnosticContext.InterfaceFunctionDescriptionsPath = jsonPath;
        diagnosticContext.InterfaceFunctionDescriptionsMarkdownPath = markdownPath;

        var records = new JsonArray();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        if (File.Exists(diagnosticContext.InterfaceAnchorAnalysisPath))
        {
            try
            {
                var anchorRoot = JsonNode.Parse(File.ReadAllText(diagnosticContext.InterfaceAnchorAnalysisPath)) as JsonObject;
                if (anchorRoot?["interface_conclusions"] is JsonArray conclusions)
                {
                    foreach (var conclusion in conclusions.OfType<JsonObject>())
                    {
                        var id = FirstNonEmpty(NodeString(conclusion, "physical_interface_id"), NodeString(conclusion, "relation_id"));
                        if (string.IsNullOrWhiteSpace(id))
                            id = $"anchor_interface_{records.Count + 1:000}";
                        var key = $"anchor::{id}";
                        if (!seen.Add(key))
                            continue;

                        records.Add(new JsonObject
                        {
                            ["source"] = "interface_anchor_analysis",
                            ["interface_id"] = id,
                            ["relation_id"] = id,
                            ["physical_interface_id"] = NodeString(conclusion, "physical_interface_id"),
                            ["group_id"] = "",
                            ["relation_source"] = "physical_interface_highlight_vlm",
                            ["evidence_class"] = "assembly_physical_interface_image",
                            ["component_pair"] = CloneJsonNode(conclusion["component_pair"]) ?? new JsonArray(),
                            ["face_pair"] = CloneJsonNode(conclusion["face_pair"]) ?? JsonValue.Create(""),
                            ["interface_class"] = NodeString(conclusion, "interface_class"),
                            ["interface_function"] = NodeString(conclusion, "interface_function"),
                            ["function_realization"] = FirstNonEmpty(NodeString(conclusion, "function_realization"), NodeString(conclusion, "interface_function")),
                            ["relative_motion"] = NodeString(conclusion, "relative_motion"),
                            ["load_or_motion_role"] = CloneJsonNode(conclusion["load_or_motion_role"]) ?? new JsonArray(),
                            ["fit_intent"] = NodeString(conclusion, "fit_intent"),
                            ["confidence"] = CloneJsonNode(conclusion["confidence"]) ?? JsonValue.Create(0),
                            ["confidence_tier"] = NodeString(conclusion, "confidence_tier"),
                            ["propagation_eligible"] = CloneJsonNode(conclusion["propagation_eligible"]) ?? JsonValue.Create(false),
                            ["evidence_refs"] = CloneJsonNode(conclusion["evidence_refs"]) ?? new JsonArray(),
                            ["image_evidence_names"] = CloneJsonNode(conclusion["image_evidence_names"]) ?? new JsonArray(),
                            ["alternatives"] = CloneJsonNode(conclusion["alternatives"]) ?? new JsonArray(),
                            ["missing_conditions"] = CloneJsonNode(conclusion["missing_conditions"]) ?? new JsonArray()
                        });
                    }
                }
            }
            catch
            {
                // Keep the standalone interface description export best-effort.
            }
        }

        if (File.Exists(diagnosticContext.FunctionalGroupDescriptionsPath))
        {
            try
            {
                var groupRoot = JsonNode.Parse(File.ReadAllText(diagnosticContext.FunctionalGroupDescriptionsPath)) as JsonObject;
                if (groupRoot?["groups"] is JsonArray groups)
                {
                    foreach (var group in groups.OfType<JsonObject>())
                    {
                        var groupId = NodeString(group, "group_id");
                        if (group["critical_interfaces"] is not JsonArray interfaces)
                            continue;
                        foreach (var item in interfaces.OfType<JsonObject>())
                        {
                            var relationId = FirstNonEmpty(NodeString(item, "relation_id"), NodeString(item, "physical_interface_id"));
                            if (string.IsNullOrWhiteSpace(relationId))
                                relationId = $"group_interface_{records.Count + 1:000}";
                            var key = $"group::{groupId}::{relationId}";
                            if (!seen.Add(key))
                                continue;

                            records.Add(new JsonObject
                            {
                                ["source"] = "functional_group_descriptions",
                                ["interface_id"] = relationId,
                                ["relation_id"] = relationId,
                                ["physical_interface_id"] = NodeString(item, "physical_interface_id"),
                                ["group_id"] = groupId,
                                ["relation_source"] = NodeString(item, "relation_source"),
                                ["evidence_class"] = NodeString(item, "evidence_class"),
                                ["component_pair"] = CloneJsonNode(item["component_pair"]) ?? new JsonArray(),
                                ["face_pair"] = CloneJsonNode(item["face_pair"]) ?? JsonValue.Create(""),
                                ["interface_class"] = NodeString(item, "interface_class"),
                                ["interface_function"] = NodeString(item, "interface_function"),
                                ["function_realization"] = NodeString(item, "function_realization"),
                                ["judgement_relevance"] = NodeString(item, "judgement_relevance"),
                                ["relative_motion"] = NodeString(item, "relative_motion"),
                                ["fit_intent"] = NodeString(item, "fit_intent"),
                                ["confidence"] = CloneJsonNode(item["confidence"]) ?? JsonValue.Create(0),
                                ["interface_scope"] = NodeString(item, "interface_scope"),
                                ["evidence_refs"] = CloneJsonNode(item["evidence_refs"]) ?? new JsonArray(),
                                ["image_evidence_names"] = CloneJsonNode(item["image_evidence_names"]) ?? new JsonArray(),
                                ["depends_on"] = CloneJsonNode(item["depends_on"]) ?? new JsonArray(),
                                ["alternatives"] = CloneJsonNode(item["alternatives"]) ?? new JsonArray(),
                                ["missing_conditions"] = CloneJsonNode(item["missing_conditions"]) ?? new JsonArray()
                            });
                        }
                    }
                }
            }
            catch
            {
                // Keep the standalone interface description export best-effort.
            }
        }

        var root = new JsonObject
        {
            ["schema"] = "assembly_interface_function_descriptions_v1",
            ["generation_mode"] = plans.All(plan => plan.IsPartDocument)
                ? "disabled_for_part_input"
                : "derived_from_physical_interface_vlm_and_functional_group_descriptions",
            ["usage_policy"] = "Standalone per-interface/contact function descriptions for downstream assembly checking and drawing tolerance binding. Component functions and interface/contact functions remain separate.",
            ["source_files"] = new JsonObject
            {
                ["interface_anchor_analysis"] = diagnosticContext.InterfaceAnchorAnalysisPath,
                ["functional_group_descriptions"] = diagnosticContext.FunctionalGroupDescriptionsPath
            },
            ["contact_function_descriptions"] = records
        };

        WriteJsonFile(jsonPath, root);
        File.WriteAllText(markdownPath, BuildInterfaceFunctionDescriptionsMarkdown(root), new UTF8Encoding(false));
    }

    static string BuildInterfaceFunctionDescriptionsMarkdown(JsonObject root)
    {
        var sb = new StringBuilder();
        sb.AppendLine("# Interface Function Descriptions");
        sb.AppendLine();
        sb.AppendLine("This file summarizes per-contact/interface function descriptions separated from whole-component functions.");
        sb.AppendLine();
        if (root["contact_function_descriptions"] is JsonArray records)
        {
            foreach (var item in records.OfType<JsonObject>())
            {
                sb.AppendLine($"## {FirstNonEmpty(NodeString(item, "relation_id"), NodeString(item, "interface_id"))}");
                sb.AppendLine();
                sb.AppendLine($"- Group: {NodeString(item, "group_id")}");
                sb.AppendLine($"- Source: {NodeString(item, "source")} / {NodeString(item, "relation_source")} / {NodeString(item, "evidence_class")}");
                sb.AppendLine($"- Components: {JoinNodeValues(item["component_pair"])}");
                sb.AppendLine($"- Face pair: {JsonNodeText(item["face_pair"])}");
                sb.AppendLine($"- Class: {NodeString(item, "interface_class")}");
                sb.AppendLine($"- Function: {NodeString(item, "interface_function")}");
                sb.AppendLine($"- Realization: {NodeString(item, "function_realization")}");
                sb.AppendLine($"- Relative motion: {NodeString(item, "relative_motion")}");
                sb.AppendLine($"- Fit intent: {NodeString(item, "fit_intent")}");
                sb.AppendLine($"- Confidence: {JsonNodeText(item["confidence"])}");
                sb.AppendLine($"- Images: {JoinNodeValues(item["image_evidence_names"])}");
                sb.AppendLine();
            }
        }
        return sb.ToString();
    }

    static async Task GenerateFunctionalGroupLocalSubgraphsAsync(
        string model,
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        DiagnosticContextArtifacts diagnosticContext,
        HttpClient http,
        string baseUrl,
        string apiMode)
    {
        var groups = UniqueFunctionalGroups(plans);
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);
        var promptPath = Path.Combine(contextDir, "functional_group_local_subgraphs_prompt.md");
        var rawPath = Path.Combine(contextDir, "functional_group_local_subgraphs_response.json");
        var jsonPath = Path.Combine(contextDir, "functional_group_local_subgraphs.json");
        diagnosticContext.FunctionalGroupLocalSubgraphsPath = jsonPath;

        if (groups.Count == 0 || plans.All(plan => plan.IsPartDocument))
        {
            WriteJsonFile(jsonPath, new
            {
                schema = "functional_group_local_subgraphs_v1",
                generation_mode = "disabled_or_no_groups",
                local_semantic_units = Array.Empty<object>(),
                local_subgraphs = Array.Empty<object>()
            });
            return;
        }

        var prompt = BuildFunctionalGroupLocalSubgraphPrompt(outputDir, plans, groups, diagnosticContext);
        File.WriteAllText(promptPath, prompt, new UTF8Encoding(false));

        try
        {
            object request = apiMode == "responses"
                ? BuildResponsesRequest(model, prompt)
                : BuildChatCompletionsRequest(model, prompt, []);
            string endpoint = apiMode == "responses" ? $"{baseUrl}/responses" : $"{baseUrl}/chat/completions";

            using var content = new StringContent(JsonSerializer.Serialize(request, JsonOptions.Default), Encoding.UTF8, "application/json");
            using var response = await http.PostAsync(endpoint, content);
            var raw = await response.Content.ReadAsStringAsync();
            File.WriteAllText(rawPath, raw, new UTF8Encoding(false));
            if (!response.IsSuccessStatusCode)
                throw new InvalidOperationException($"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}");

            var text = ExtractResponseText(raw);
            var json = ExtractJsonObjectWithSchema(text, "functional_group_local_subgraphs_v1");
            if (string.IsNullOrWhiteSpace(json))
                throw new InvalidOperationException("functional_group_local_subgraphs_v1 JSON was not found in AI response.");

            File.WriteAllText(jsonPath, NormalizeFunctionalGroupLocalSubgraphJson(json), new UTF8Encoding(false));
        }
        catch (Exception ex)
        {
            WriteJsonFile(jsonPath, BuildFallbackFunctionalGroupLocalSubgraphs(groups, ex.Message));
        }
    }

    static string NormalizeFunctionalGroupLocalSubgraphJson(string json)
    {
        try
        {
            var root = JsonNode.Parse(json) as JsonObject;
            if (root == null)
                return NormalizeJson(json);

            root["schema"] = "functional_group_local_subgraphs_v1";
            root["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck;
            root["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly;
            root["allowed_anchor_types"] = new JsonArray("functional_group", "interface");
            root["forbidden_anchor_types"] = new JsonArray("part", "feature");

            if (root["local_semantic_units"] is JsonArray units)
            {
                foreach (var unit in units.OfType<JsonObject>())
                {
                    unit["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck;
                    unit["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly;
                    var anchorType = NodeString(unit, "anchor_type");
                    if (string.IsNullOrWhiteSpace(anchorType) ||
                        anchorType.Equals("part", StringComparison.OrdinalIgnoreCase) ||
                        anchorType.Equals("feature", StringComparison.OrdinalIgnoreCase))
                    {
                        unit["anchor_type"] = "functional_group";
                    }
                }
            }

            return root.ToJsonString(JsonOptions.Default);
        }
        catch
        {
            return NormalizeJson(json);
        }
    }

    static string BuildFunctionalGroupLocalSubgraphPrompt(
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        IReadOnlyList<JsonObject> groups,
        DiagnosticContextArtifacts diagnosticContext)
    {
        var payload = new
        {
            schema = "functional_group_local_subgraph_input_v1",
            diagnostic_stage = DiagnosticFlowTerms.StageFunctionalGroupCheck,
            knowledge_scope = DiagnosticFlowTerms.KnowledgeScopeAssembly,
            task = "Generate functional-group local subgraphs before any part-level checking.",
            evidence_boundary = "Use functional-group descriptions, component function profiles, interface_subset.json, B-rep contact/mate/interface facts, and assembly/function-group dynamic scout cards only. Do not use all_face_highlights or full per-face images.",
            output_contract = new
            {
                diagnostic_stage = DiagnosticFlowTerms.StageFunctionalGroupCheck,
                knowledge_scope = DiagnosticFlowTerms.KnowledgeScopeAssembly,
                allowed_anchor_types = new[] { "functional_group", "interface" },
                forbidden_anchor_types = new[] { "part", "feature" },
                downstream_knowledge_library = "assembly/function-group knowledge library only"
            },
            functional_group_descriptions_path = diagnosticContext.FunctionalGroupDescriptionsPath,
            functional_group_descriptions = File.Exists(diagnosticContext.FunctionalGroupDescriptionsPath)
                ? JsonNode.Parse(File.ReadAllText(diagnosticContext.FunctionalGroupDescriptionsPath))
                : null,
            groups = groups.Select(group =>
            {
                var groupId = NodeString(group, "group_id");
                var interfaceSubsetPath = Path.Combine(outputDir, "functional_group_evidence", SanitizeFileName(groupId), "interface_subset.json");
                return new
                {
                    group_id = groupId,
                    interface_subset_path = interfaceSubsetPath,
                    interface_subset = File.Exists(interfaceSubsetPath)
                        ? JsonNode.Parse(File.ReadAllText(interfaceSubsetPath))
                        : null,
                    group_context = group,
                    dynamic_scout_cards = group["matched_functional_group_inspection_cards"]?.DeepClone()
                };
            }).ToList()
        };
        var json = JsonSerializer.Serialize(payload, JsonOptions.Default);
        return $$"""
You are a mechanical assembly diagnostic expert. Answer in Chinese.

Task:
- Generate local subgraphs and local semantic units for functional-group design checking only.
- This stage runs after functional-group descriptions and before part-level checks.
- Use component function profiles, interface_subset.json, relation-scoped B-rep faces, explicit mates, B-rep-derived contacts/coincident/flush/coaxial/near-zero-gap facts, and assembly/function-group dynamic scout cards.
- Do not use all_face_highlights, all_face_highlight_export_manifest, full all-face image inventories, or unrelated full part faces.
- Do not generate part-level feature checks here. Part/feature checks run later from group_part_brep.json and all_face_highlights.
- retrieval_sentence must be one neutral Chinese fact sentence. Do not include rule ids, violation claims, or words such as 错误、违反、应该、不应.
- Every local_semantic_units item must include component_id, instance_name, secondary_views, retrieval_semantic_roles, retrieval_fact_tags, retrieval_relations, image_evidence_names, fact_bundle, and a stable dedup_key derived from the concrete interface relation signature.
- Cite exact assembly_physical_interface image names supplied by relation evidence. When an ordinary mate or single planar contact intentionally has no exported interface image, leave image_evidence_names empty and set fact_bundle.image_evidence_status to no_exported_interface_image_for_anchor.
- Return exactly one fenced JSON object. No prose outside the JSON block.

Return schema:
```json
{
  "schema": "functional_group_local_subgraphs_v1",
  "generation_mode": "ai_after_functional_group_description_before_part_checks",
  "diagnostic_stage": "{{DiagnosticFlowTerms.StageFunctionalGroupCheck}}",
  "knowledge_scope": "{{DiagnosticFlowTerms.KnowledgeScopeAssembly}}",
  "local_semantic_units": [
    {
      "local_id": "fg_unit_001",
      "linked_subgraph_id": "fg_subgraph_001",
      "diagnostic_stage": "{{DiagnosticFlowTerms.StageFunctionalGroupCheck}}",
      "knowledge_scope": "{{DiagnosticFlowTerms.KnowledgeScopeAssembly}}",
      "anchor_type": "functional_group",
      "anchor_id": "functional_group_001",
      "component_id": "component_1",
      "instance_name": "参与接口的主要组件实例名",
      "pattern_type": "functional_group_interface",
      "primary_view": "assembly_contact_fit",
      "secondary_views": [],
      "retrieval_sentence": "中性检索事实句",
      "retrieval_chain": {
        "geometric_fact_for_retrieval": "",
        "mechanical_role": "",
        "process_assembly_maintenance_semantics": ""
      },
      "inspection_card_refs": [],
      "evidence_refs": [],
      "retrieval_semantic_roles": [],
      "retrieval_fact_tags": [],
      "retrieval_relations": [],
      "image_evidence_names": [],
      "fact_bundle": {
        "component_id": "component_1",
        "instance_name": "参与接口的主要组件实例名",
        "review_scope": "part_design_required",
        "semantic_location": "functional_group_001/interface relation signature",
        "evidence_refs": [],
        "image_evidence_names": [],
        "image_evidence_status": "bound_images_present|no_exported_interface_image_for_anchor",
        "neighbor_evidence_refs": [],
        "roles": [],
        "measurements": {},
        "uncertainties": []
      },
      "dedup_key": "functional_group_001:assembly_contact_fit:stable_relation_signature"
    }
  ],
  "local_subgraphs": [
    {
      "id": "fg_subgraph_001",
      "group_id": "functional_group_001",
      "pattern_type": "functional_group_interface",
      "primary_view": "assembly_contact_fit",
      "retrieval_sentence": "中性检索事实句",
      "roles": [],
      "evidence": [],
      "image_evidence_names": [],
      "critical_interfaces": [],
      "confidence": "low|medium|high",
      "reason": ""
    }
  ],
  "missing_facts": []
}
```

Functional-group evidence JSON:
```json
{{json}}
```
""";
    }

    static JsonObject BuildFallbackFunctionalGroupLocalSubgraphs(IReadOnlyList<JsonObject> groups, string message)
    {
        var units = new JsonArray();
        var subgraphs = new JsonArray();
        var index = 0;
        foreach (var group in groups)
        {
            var groupId = NodeString(group, "group_id");
            if (string.IsNullOrWhiteSpace(groupId))
                continue;
            index++;
            var subgraphId = $"fg_subgraph_{index:000}";
            var primaryMember = (group["member_components"] as JsonArray)?.OfType<JsonObject>().FirstOrDefault();
            var componentId = primaryMember == null ? "" : NodeString(primaryMember, "component_id");
            var instanceName = primaryMember == null ? "" : NodeString(primaryMember, "instance_name");
            units.Add(new JsonObject
            {
                ["local_id"] = $"fg_unit_{index:000}",
                ["linked_subgraph_id"] = subgraphId,
                ["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck,
                ["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly,
                ["anchor_type"] = "functional_group",
                ["anchor_id"] = groupId,
                ["component_id"] = componentId,
                ["instance_name"] = instanceName,
                ["pattern_type"] = "functional_group_interface",
                ["primary_view"] = "assembly_contact_fit",
                ["secondary_views"] = new JsonArray(),
                ["retrieval_sentence"] = $"{groupId} 包含基于接触和配合关系形成的功能接口。",
                ["retrieval_chain"] = new JsonObject
                {
                    ["geometric_fact_for_retrieval"] = "功能组包含接触、配合或位置邻接接口。",
                    ["mechanical_role"] = "功能组接口承担定位、支撑、连接、导向或传力等装配作用。",
                    ["process_assembly_maintenance_semantics"] = "功能组接口需要后续与装配知识库详细判定块对比。"
                },
                ["inspection_card_refs"] = new JsonArray(),
                ["evidence_refs"] = new JsonArray(groupId),
                ["retrieval_semantic_roles"] = new JsonArray(),
                ["retrieval_fact_tags"] = new JsonArray(),
                ["retrieval_relations"] = new JsonArray(),
                ["image_evidence_names"] = new JsonArray(),
                ["fact_bundle"] = new JsonObject
                {
                    ["component_id"] = componentId,
                    ["instance_name"] = instanceName,
                    ["review_scope"] = "part_design_required",
                    ["semantic_location"] = groupId,
                    ["evidence_refs"] = new JsonArray(groupId),
                    ["image_evidence_names"] = new JsonArray(),
                    ["image_evidence_status"] = "no_exported_interface_image_for_anchor",
                    ["neighbor_evidence_refs"] = new JsonArray(),
                    ["roles"] = new JsonArray(),
                    ["measurements"] = new JsonObject(),
                    ["uncertainties"] = new JsonArray()
                },
                ["dedup_key"] = $"{groupId}:assembly_contact_fit:fallback"
            });
            subgraphs.Add(new JsonObject
            {
                ["id"] = subgraphId,
                ["group_id"] = groupId,
                ["pattern_type"] = "functional_group_interface",
                ["primary_view"] = "assembly_contact_fit",
                ["retrieval_sentence"] = $"{groupId} 包含基于接触和配合关系形成的功能接口。",
                ["roles"] = new JsonArray(),
                ["evidence"] = new JsonArray(CloneJsonNode(group)),
                ["image_evidence_names"] = new JsonArray(),
                ["critical_interfaces"] = CloneJsonNode(group["interfaces"]) ?? new JsonArray(),
                ["confidence"] = "low",
                ["reason"] = $"fallback: {message}"
            });
        }

        return new JsonObject
        {
            ["schema"] = "functional_group_local_subgraphs_v1",
            ["generation_mode"] = "fallback_after_functional_group_description",
            ["diagnostic_stage"] = DiagnosticFlowTerms.StageFunctionalGroupCheck,
            ["knowledge_scope"] = DiagnosticFlowTerms.KnowledgeScopeAssembly,
            ["allowed_anchor_types"] = new JsonArray("functional_group", "interface"),
            ["forbidden_anchor_types"] = new JsonArray("part", "feature"),
            ["message"] = message,
            ["local_semantic_units"] = units,
            ["local_subgraphs"] = subgraphs,
            ["missing_facts"] = new JsonArray()
        };
    }

    static Dictionary<string, ComponentPromptPlan> BuildComponentLookup(IReadOnlyList<ComponentPromptPlan> plans)
    {
        var result = new Dictionary<string, ComponentPromptPlan>(StringComparer.OrdinalIgnoreCase);
        foreach (var plan in plans)
        {
            AddLookup(plan.ComponentId);
            AddLookup(plan.InstanceName);
            AddLookup(plan.DisplayName);
            AddLookup(ReadString(plan.Package, "Path"));
            AddLookup(ReadString(plan.Package, "FileName"));

            void AddLookup(string key)
            {
                if (!string.IsNullOrWhiteSpace(key))
                    result.TryAdd(key, plan);
            }
        }
        return result;
    }

    static JsonArray BuildFunctionGroupInterfaces(
        IReadOnlyList<ComponentPromptPlan> plans,
        IReadOnlyDictionary<string, ComponentPromptPlan> componentLookup)
    {
        var result = new JsonArray();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        foreach (var contact in plans.SelectMany(plan => plan.Contacts))
        {
            var relationId = FirstNonEmpty(ReadString(contact, "Id"), $"contact_{seen.Count + 1:000}");
            if (!seen.Add($"contact:{relationId}"))
                continue;

            var componentA = ResolveRelationComponent(componentLookup, ReadString(contact, "ComponentA"), ReadString(contact, "ComponentDisplayNameA"), ReadString(contact, "ComponentPathA"), ReadString(contact, "ComponentFileNameA"));
            var componentB = ResolveRelationComponent(componentLookup, ReadString(contact, "ComponentB"), ReadString(contact, "ComponentDisplayNameB"), ReadString(contact, "ComponentPathB"), ReadString(contact, "ComponentFileNameB"));
            if (componentA == null && componentB == null)
                continue;

            var faceA = ReadInt(contact, "FaceA");
            var faceB = ReadInt(contact, "FaceB");
            if (faceA <= 0 && faceB <= 0)
                continue;

            result.Add(new JsonObject
            {
                ["relation_id"] = relationId,
                ["relation_type"] = "brep_contact",
                ["relation_source"] = "brep_geometry_contact",
                ["evidence_class"] = ClassifyBrepInterfaceEvidence(contact),
                ["contact_certainty"] = EstimateBrepInterfaceCertainty(contact),
                ["functional_interface_hint"] = InferFunctionalInterfaceHint(contact),
                ["relation_role"] = FirstNonEmpty(ReadString(contact, "MateRole"), ReadString(contact, "ContactKind"), "contact"),
                ["geometric_contact_basis"] = "B-rep face pair spatial relation such as near-zero gap, coplanar/parallel normals, overlapping planar regions, coaxial cylinders, matching radius, positional coincidence, or direct contact candidate.",
                ["component_a_id"] = componentA?.ComponentId ?? "",
                ["component_a_name"] = FirstNonEmpty(ReadString(contact, "ComponentDisplayNameA"), ReadString(contact, "ComponentA")),
                ["face_a_ref"] = faceA > 0 ? $"{FirstNonEmpty(ReadString(contact, "ComponentA"), componentA?.InstanceName)}:face#{faceA}" : "",
                ["face_a_kind"] = ReadString(contact, "FaceKindA"),
                ["face_a_role"] = FirstNonEmpty(ReadString(contact, "FaceRoleA"), ReadString(contact, "SurfaceProcessRoleA")),
                ["component_b_id"] = componentB?.ComponentId ?? "",
                ["component_b_name"] = FirstNonEmpty(ReadString(contact, "ComponentDisplayNameB"), ReadString(contact, "ComponentB")),
                ["face_b_ref"] = faceB > 0 ? $"{FirstNonEmpty(ReadString(contact, "ComponentB"), componentB?.InstanceName)}:face#{faceB}" : "",
                ["face_b_kind"] = ReadString(contact, "FaceKindB"),
                ["face_b_role"] = FirstNonEmpty(ReadString(contact, "FaceRoleB"), ReadString(contact, "SurfaceProcessRoleB")),
                ["process_pair"] = new JsonObject
                {
                    ["a"] = ReadString(contact, "SurfaceProcessRoleA"),
                    ["b"] = ReadString(contact, "SurfaceProcessRoleB"),
                    ["confidence_a"] = ReadString(contact, "ProcessConfidenceA"),
                    ["confidence_b"] = ReadString(contact, "ProcessConfidenceB")
                },
                ["fit_metrics"] = new JsonObject
                {
                    ["gap_mm"] = ReadNullableNumber(contact, "GapMm"),
                    ["normal_dot"] = ReadNullableNumber(contact, "NormalDot"),
                    ["overlap_measure_mm2"] = ReadNullableNumber(contact, "OverlapMeasureMm2"),
                    ["axis_distance_mm"] = ReadNullableNumber(contact, "AxisDistanceMm"),
                    ["axial_overlap_mm"] = ReadNullableNumber(contact, "AxialOverlapMm"),
                    ["radius_a_mm"] = ReadNullableNumber(contact, "RadiusAmm"),
                    ["radius_b_mm"] = ReadNullableNumber(contact, "RadiusBmm"),
                    ["confidence"] = ReadNullableNumber(contact, "Confidence")
                },
                ["evidence"] = FirstNonEmpty(ReadString(contact, "Evidence"), ReadString(contact, "ProcessBasisA"), ReadString(contact, "ProcessBasisB")),
                ["diagnostic_use"] = "Use this B-rep-derived interface to judge functional-group design even when there is no explicit SolidWorks mate: locating/support/fit/transmission/sealing/guiding/fastening and whether the participating faces are appropriate."
            });
        }

        foreach (var mate in plans.SelectMany(plan => plan.Mates))
        {
            var relationId = FirstNonEmpty(ReadString(mate, "Id"), $"mate_{seen.Count + 1:000}");
            if (!seen.Add($"mate:{relationId}"))
                continue;

            var componentA = ResolveRelationComponent(componentLookup, ReadString(mate, "ComponentA"), ReadString(mate, "ComponentDisplayNameA"), ReadString(mate, "ComponentPathA"), ReadString(mate, "ComponentFileNameA"));
            var componentB = ResolveRelationComponent(componentLookup, ReadString(mate, "ComponentB"), ReadString(mate, "ComponentDisplayNameB"), ReadString(mate, "ComponentPathB"), ReadString(mate, "ComponentFileNameB"));
            if (componentA == null && componentB == null)
                continue;

            result.Add(new JsonObject
            {
                ["relation_id"] = relationId,
                ["relation_type"] = "solidworks_mate",
                ["relation_source"] = "solidworks_mate",
                ["evidence_class"] = "explicit_mate",
                ["contact_certainty"] = "explicit",
                ["functional_interface_hint"] = InferMateFunctionalInterfaceHint(mate),
                ["relation_role"] = FirstNonEmpty(ReadString(mate, "MateTypeName"), ReadString(mate, "MateName"), "mate"),
                ["component_a_id"] = componentA?.ComponentId ?? "",
                ["component_a_name"] = FirstNonEmpty(ReadString(mate, "ComponentDisplayNameA"), ReadString(mate, "ComponentA")),
                ["face_a_ref"] = "",
                ["component_b_id"] = componentB?.ComponentId ?? "",
                ["component_b_name"] = FirstNonEmpty(ReadString(mate, "ComponentDisplayNameB"), ReadString(mate, "ComponentB")),
                ["face_b_ref"] = "",
                ["evidence"] = FirstNonEmpty(ReadString(mate, "Evidence"), "solidworks_mate"),
                ["diagnostic_use"] = "Mate relation is included as functional-group relation evidence; use contact entries for concrete face refs when available."
            });
        }

        return result;
    }

    static string ClassifyBrepInterfaceEvidence(JsonElement contact)
    {
        var faceKindA = ReadString(contact, "FaceKindA");
        var faceKindB = ReadString(contact, "FaceKindB");
        var gap = ReadNullableNumber(contact, "GapMm");
        var normalDot = ReadNullableNumber(contact, "NormalDot");
        var overlap = ReadNullableNumber(contact, "OverlapMeasureMm2");
        var axisDistance = ReadNullableNumber(contact, "AxisDistanceMm");
        var axialOverlap = ReadNullableNumber(contact, "AxialOverlapMm");
        var radiusA = ReadNullableNumber(contact, "RadiusAmm");
        var radiusB = ReadNullableNumber(contact, "RadiusBmm");
        var text = $"{ReadString(contact, "ContactKind")} {ReadString(contact, "MateRole")} {faceKindA} {faceKindB}".ToLowerInvariant();

        if ((text.Contains("cyl") || text.Contains("圆柱")) &&
            axisDistance is <= 0.05 &&
            axialOverlap is > 0 &&
            radiusA is > 0 &&
            radiusB is > 0)
        {
            return "brep_coaxial_fit";
        }

        if ((text.Contains("plane") || text.Contains("planar") || text.Contains("平面")) &&
            gap is <= 0.05 &&
            normalDot.HasValue &&
            Math.Abs(Math.Abs(normalDot.Value) - 1.0) <= 0.08 &&
            overlap is > 0)
        {
            return "brep_planar_contact";
        }

        if (gap is <= 0.05)
            return "brep_near_zero_gap_contact";

        if (axisDistance is <= 0.1 && axialOverlap is > 0)
            return "brep_coaxial_or_aligned_candidate";

        return "brep_spatial_contact_candidate";
    }

    static string EstimateBrepInterfaceCertainty(JsonElement contact)
    {
        var confidence = ReadNullableNumber(contact, "Confidence");
        if (confidence is >= 0.8)
            return "high";
        if (confidence is >= 0.45)
            return "medium";

        var gap = ReadNullableNumber(contact, "GapMm");
        var overlap = ReadNullableNumber(contact, "OverlapMeasureMm2");
        var axisDistance = ReadNullableNumber(contact, "AxisDistanceMm");
        if (gap is <= 0.02 && overlap is > 0)
            return "high";
        if (gap is <= 0.05 || axisDistance is <= 0.05)
            return "medium";
        return "low";
    }

    static string InferFunctionalInterfaceHint(JsonElement contact)
    {
        var text = string.Join(" ", new[]
        {
            ReadString(contact, "MateRole"),
            ReadString(contact, "ContactKind"),
            ReadString(contact, "FaceRoleA"),
            ReadString(contact, "FaceRoleB"),
            ReadString(contact, "SurfaceProcessRoleA"),
            ReadString(contact, "SurfaceProcessRoleB"),
            ReadString(contact, "Evidence")
        }).ToLowerInvariant();

        if (text.Contains("seal") || text.Contains("密封"))
            return "sealing_interface";
        if (text.Contains("guide") || text.Contains("slide") || text.Contains("导向") || text.Contains("滑动"))
            return "guiding_or_sliding_interface";
        if (text.Contains("coax") || text.Contains("cyl") || text.Contains("同轴") || text.Contains("圆柱"))
            return "centering_or_rotational_fit_interface";
        if (text.Contains("locat") || text.Contains("datum") || text.Contains("定位"))
            return "locating_interface";
        if (text.Contains("support") || text.Contains("bearing") || text.Contains("支撑"))
            return "supporting_interface";
        if (text.Contains("fasten") || text.Contains("bolt") || text.Contains("螺"))
            return "fastening_interface";
        return "contact_or_load_transfer_interface";
    }

    static string InferMateFunctionalInterfaceHint(JsonElement mate)
    {
        var text = $"{ReadString(mate, "MateTypeName")} {ReadString(mate, "MateName")} {ReadString(mate, "Evidence")}".ToLowerInvariant();
        if (text.Contains("coincident") || text.Contains("重合"))
            return "locating_or_flush_mate_interface";
        if (text.Contains("concentric") || text.Contains("同心") || text.Contains("同轴"))
            return "centering_or_rotational_fit_interface";
        if (text.Contains("distance") || text.Contains("距离"))
            return "clearance_or_positioning_mate_interface";
        if (text.Contains("parallel") || text.Contains("平行"))
            return "orientation_constraint_interface";
        return "explicit_assembly_constraint_interface";
    }

    static List<List<string>> BuildRelationConnectedGroups(IReadOnlyList<ComponentPromptPlan> plans, JsonArray interfaces)
    {
        var known = plans.Select(plan => plan.ComponentId).Where(id => !string.IsNullOrWhiteSpace(id)).ToHashSet(StringComparer.OrdinalIgnoreCase);
        var adjacency = known.ToDictionary(id => id, _ => new HashSet<string>(StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase);
        foreach (var item in interfaces.OfType<JsonObject>())
        {
            var a = NodeString(item, "component_a_id");
            var b = NodeString(item, "component_b_id");
            if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b) || !known.Contains(a) || !known.Contains(b))
                continue;
            adjacency[a].Add(b);
            adjacency[b].Add(a);
        }

        var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var groups = new List<List<string>>();
        foreach (var id in known.OrderBy(id => id, StringComparer.OrdinalIgnoreCase))
        {
            if (!visited.Add(id))
                continue;
            var queue = new Queue<string>();
            var group = new List<string>();
            queue.Enqueue(id);
            while (queue.Count > 0)
            {
                var current = queue.Dequeue();
                group.Add(current);
                foreach (var next in adjacency[current])
                {
                    if (visited.Add(next))
                        queue.Enqueue(next);
                }
            }
            if (group.Count > 1 || interfaces.OfType<JsonObject>().Any(item => NodeString(item, "component_a_id").Equals(id, StringComparison.OrdinalIgnoreCase) || NodeString(item, "component_b_id").Equals(id, StringComparison.OrdinalIgnoreCase)))
                groups.Add(group.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToList());
        }

        return groups;
    }

    static ComponentPromptPlan? ResolveRelationComponent(
        IReadOnlyDictionary<string, ComponentPromptPlan> lookup,
        params string[] keys)
    {
        foreach (var key in keys)
        {
            if (!string.IsNullOrWhiteSpace(key) && lookup.TryGetValue(key, out var plan))
                return plan;
        }
        return null;
    }

    static double? ReadNullableNumber(JsonElement element, string property)
    {
        return element.ValueKind == JsonValueKind.Object &&
               element.TryGetProperty(property, out var value) &&
               value.ValueKind == JsonValueKind.Number &&
               value.TryGetDouble(out var number)
            ? number
            : null;
    }

    static string ComponentProfileString(ComponentPromptPlan plan, string property) =>
        plan.AssemblyFunctionProfile == null ? "" : NodeString(plan.AssemblyFunctionProfile, property);

    static JsonObject BuildUserPartFunctionProfile(ComponentPromptPlan plan)
    {
        var text = plan.UserPartFunctionProfile.Trim();
        return new JsonObject
        {
            ["schema"] = "user_part_function_profile_v1",
            ["component_id"] = plan.ComponentId,
            ["component_name"] = plan.InstanceName,
            ["display_name"] = plan.DisplayName,
            ["context_policy"] = "user_supplied_for_single_part_input",
            ["function_introduction"] = text,
            ["assembly_function"] = text,
            ["part_role"] = string.IsNullOrWhiteSpace(text) ? "user_function_profile_missing" : text,
            ["related_components"] = new JsonArray(),
            ["interface_relations"] = new JsonArray(),
            ["responsibilities"] = string.IsNullOrWhiteSpace(text)
                ? new JsonArray(JsonValue.Create("missing_user_supplied_function_profile"))
                : new JsonArray(JsonValue.Create(text)),
            ["evidence_refs"] = new JsonArray("user_part_function_profile"),
            ["confidence"] = string.IsNullOrWhiteSpace(text) ? "missing" : "user_supplied"
        };
    }

    static JsonObject ComponentFunctionProfileForPrompt(ComponentPromptPlan plan)
    {
        var source = plan.AssemblyFunctionProfile ?? JsonSerializer.SerializeToNode(plan.PartFunctionProfile, JsonOptions.Default) as JsonObject;
        var profile = source == null ? new JsonObject() : CloneJsonObject(source) ?? new JsonObject();
        profile["component_id"] = plan.ComponentId;
        profile["instance_name"] = plan.InstanceName;
        profile["display_name"] = plan.DisplayName;
        profile["usage_policy"] = "This function portrait must be read in every part-level LLM call before judging faces/features. Use it to decide functional faces, processing necessity, access, support, locating, sealing, guiding, transmission, fastening, and non-functional surfaces.";
        profile["source"] = plan.IsPartDocument
            ? "user_supplied_part_function_profile"
            : "assembly_component_function_profile_generated_before_functional_group_split";
        return profile;
    }

    static JsonObject? CloneJsonObject(JsonObject? value) =>
        value == null ? null : JsonNode.Parse(value.ToJsonString(JsonOptions.Default)) as JsonObject;

    static object BuildAssemblyStructureSummary(string structureGraphPath, IReadOnlyList<ComponentPromptPlan> plans)
    {
        var edges = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var relationDtos = new List<object>();
        foreach (var plan in plans)
        {
            foreach (var neighborId in plan.AdjacentComponentIds)
            {
                var a = string.Compare(plan.ComponentId, neighborId, StringComparison.OrdinalIgnoreCase) <= 0 ? plan.ComponentId : neighborId;
                var b = string.Equals(a, plan.ComponentId, StringComparison.OrdinalIgnoreCase) ? neighborId : plan.ComponentId;
                var key = $"{a}|{b}";
                if (!edges.Add(key))
                    continue;
                relationDtos.Add(new
                {
                    a,
                    b,
                    relation_hint = InferRelationHint(plan, neighborId)
                });
            }
        }

        return new
        {
            schema = "assembly_structure_summary_v1",
            source_structure_graph_path = structureGraphPath,
            generation_mode = "programmatic_low_resolution_summary",
            usage_policy = "This file is a compact device-level index. It intentionally excludes full B-rep, faces, images, and unrelated component details.",
            component_count = plans.Count,
            components = plans.Select(plan => new
            {
                component_id = plan.ComponentId,
                instance_name = plan.InstanceName,
                display_name = plan.DisplayName,
                shape_hint = InferShapeHint(plan),
                feature_count = plan.Features.Count,
                face_count = plan.Faces.Count,
                contact_count = plan.Contacts.Count,
                mate_count = plan.Mates.Count,
                adjacent_component_ids = plan.AdjacentComponentIds,
                graph_role_hint = plan.AdjacentComponentIds.Count >= 8 || plan.Contacts.Count + plan.Mates.Count >= 20
                    ? "assembly_hub_or_support_candidate"
                    : "ordinary_component"
            }).ToList(),
            relations = relationDtos
        };
    }

    static string BuildStaticScoutRules() => """
# Static Mechanical Diagnostic Scout Rules

Use these rules only while extracting local diagnostic evidence. They are not final judgement rules.

1. First infer the target component's local evidence, then infer face/hole/feature roles. Do not treat a role inference as an observed fact.
2. Prioritize high-risk mechanical structures: abrupt section changes, sharp inner corners, deep/narrow slots, closed pockets, thin walls, protrusions on turning surfaces, cross holes, hole groups, mating/contact faces, locating faces, sliding or rotating interfaces, and obstructed assembly/tool paths.
3. For every local unit, separate evidence into observed B-rep facts, derived geometric measurements, AI-inferred functions, default process assumptions, and missing facts.
4. For holes, check diameter, axis, entrance/exit faces, through/blind status, nearby faces, hole groups, and whether the role is fastening, locating, guiding, process, or relief.
   B-rep `RadiusMm`/`radius_mm` is a radius, not a diameter. When describing a hole diameter, use `DiameterMm`/`diameter_mm` if present, or compute diameter = 2 * radius.
5. For faces, check surface type, area, neighboring faces, contact/mate evidence, step height to surrounding surfaces, and whether the face is likely machined, unmachined, locating, supporting, sealing, sliding, or clearance.
6. For assembly interfaces, check direct contact/mate relations, coaxial/coplanar/opposing-normal status, clearance, locating role, and whether neighbor detail is required.
7. If evidence is missing, request a scoped expansion: whole_part, interface_pair, functional_block, assembly_installation, or kinematic_chain. Do not request all unrelated components.
""";

    static FunctionContext BuildHeuristicFunctionContext(IReadOnlyList<ComponentPromptPlan> plans)
    {
        var blockMap = new Dictionary<string, FunctionBlockDraft>(StringComparer.OrdinalIgnoreCase);
        var profiles = new List<PartFunctionProfileDraft>();
        foreach (var plan in plans)
        {
            var blockId = InferFunctionalBlockId(plan);
            if (!blockMap.TryGetValue(blockId, out var block))
            {
                block = CreateFunctionBlock(blockId);
                blockMap[blockId] = block;
            }
            block.ComponentIds.Add(plan.ComponentId);

            var role = InferPartRole(plan, blockId);
            profiles.Add(new PartFunctionProfileDraft
            {
                ComponentId = plan.ComponentId,
                ComponentName = plan.InstanceName,
                DisplayName = plan.DisplayName,
                FunctionalBlockId = blockId,
                PartRole = role,
                MainInterfaces = plan.AdjacentComponentIds.Take(8).ToList(),
                Confidence = string.Equals(blockId, "unclassified_components", StringComparison.OrdinalIgnoreCase) ? "low" : "medium",
                Evidence = ["component name", "direct contact/mate graph", "component degree"]
            });
        }

        return new FunctionContext
        {
            Blocks = blockMap.Values
                .OrderBy(block => block.BlockId, StringComparer.OrdinalIgnoreCase)
                .ToList(),
            Profiles = profiles
                .OrderBy(profile => profile.ComponentId, StringComparer.OrdinalIgnoreCase)
                .ToList()
        };
    }

    static FunctionBlockDraft CreateFunctionBlock(string blockId)
    {
        var (name, function, confidence) = blockId switch
        {
            "wrist_drive_module_1" => ("wrist_drive_reduction_module_1", "motor, flange, and reducer related drive/reduction module", "medium"),
            "wrist_drive_module_2" => ("wrist_drive_reduction_module_2", "second motor, flange, and reducer related drive/reduction module", "medium"),
            "screw_slider_branch_1" => ("screw_slider_branch_1", "screw, slider, link, and moving connector transmission branch", "medium"),
            "screw_slider_branch_2" => ("screw_slider_branch_2", "second screw, slider, link, and moving connector transmission branch", "medium"),
            "front_support_frame" => ("front_support_frame", "support, shaft sleeve, and locating connector structure", "medium"),
            "joint_caps_and_link_interfaces" => ("joint_caps_and_link_interfaces", "joint caps and link interfaces for rotation, locating, and motion transfer", "medium"),
            _ => ("unclassified_components", "No stable functional block was inferred from local evidence.", "low")
        };
        return new FunctionBlockDraft
        {
            BlockId = blockId,
            Name = name,
            Function = function,
            Confidence = confidence
        };
    }

    static string InferFunctionalBlockId(ComponentPromptPlan plan)
    {
        var text = $"{plan.ComponentId} {plan.InstanceName} {plan.DisplayName}".ToLowerInvariant();
        if (text.Contains("涓濇潬鏉?1") || text.Contains("涓濇潬婊戝潡-1") || text.Contains("杩炴帴鏉?-1") || text.Contains("杩愬姩杩炴帴浠?2"))
            return "screw_slider_branch_1";
        if (text.Contains("涓濇潬鏉?2") || text.Contains("涓濇潬婊戝潡-2") || text.Contains("杩炴帴鏉?-2") || text.Contains("杩愬姩杩炴帴浠?1"))
            return "screw_slider_branch_2";
        if (text.Contains("鐞冨叧鑺傚附"))
            return "joint_caps_and_link_interfaces";
        if (text.Contains("鏀灦") || text.Contains("鎵嬫帉鍚庣") || text.Contains("杞村") || text.Contains("鏀偣"))
            return "front_support_frame";
        if (text.Contains("鑵曞叧鑺傝閰?1") || text.Contains("鑵曞叧鑺傜數鏈?1") || text.Contains("娉曞叞-1") || text.Contains("csf-8-2xh-f (3) body-1"))
            return "wrist_drive_module_1";
        if (text.Contains("鑵曞叧鑺傝閰?2") || text.Contains("娉曞叞-1") && plan.AdjacentComponentIds.Contains("component_18", StringComparer.OrdinalIgnoreCase))
            return "wrist_drive_module_2";
        return "unclassified_components";
    }

    static string InferPartRole(ComponentPromptPlan plan, string blockId)
    {
        var text = $"{plan.InstanceName} {plan.DisplayName}".ToLowerInvariant();
        if (text.Contains("screw") || text.Contains("lead")) return "screw-like transmission part; role must be verified from B-rep and direct relations.";
        if (text.Contains("slider")) return "slider-like part; role must be verified from B-rep and direct relations.";
        if (text.Contains("link")) return "link-like transmission connector; role must be verified from B-rep and direct relations.";
        if (text.Contains("joint") || text.Contains("cap")) return "joint cap or rotation-interface part; role must be verified from B-rep and direct relations.";
        if (text.Contains("motor")) return "motor or drive-related part.";
        if (text.Contains("flange")) return "mounting/locating flange-like part.";
        if (text.Contains("support") || text.Contains("bracket")) return "support or bracket-like part.";
        if (text.Contains("sleeve") || text.Contains("bushing")) return "sleeve/bushing-like part.";
        if (blockId.StartsWith("wrist_drive", StringComparison.OrdinalIgnoreCase)) return "part in wrist-drive module; local function must be verified from B-rep and direct relations.";
        return "general mechanical part; local function must be inferred from B-rep, images, and direct relations.";
    }

    static string BuildFunctionalGroupStaticScoutRules()
    {
        return """
# 装配体/功能组知识库静态侦察块

用途：引导功能组描述和功能组局部子图生成，使其优先覆盖装配、配合、拆装、导向、定位、操作空间和同时装入等装配体/功能组相关风险。当前种子规则主要来自第19章，后续可扩展到更多章节或自定义规则。

必须优先观察：
- 功能组内零件之间是否存在多个同时装入的配合面。
- 接触/配合面是否有明确装入方向、导向面、倒角或引导结构。
- 拆装路径、操作空间、维护可达性是否被相邻零件遮挡。
- 同轴圆柱配合、平面贴合、定位支撑关系是否形成过约束或难装配关系。
- 柔性套、轴承、销、套筒、壳体孔、端盖等装配接口是否缺少引导或必要过渡。
- 接口关系是否必须依赖多个零件组合加工或装配后再加工。

输出要求：
- 只生成中性的检索事实，不输出错误结论。
- 优先锚定功能组、接口关系、接触/配合面。
- 不把零件全部面作为功能组描述输入，只使用接口子集和功能组上下文。
""";
    }

    static List<InspectionCard> BuildInspectionCardsFromKnowledgeBase(string storeFileName = "chapter18_rule_store_v1.json", string indexFileName = "chapter18_rule_index_v1.json", Func<JsonElement, bool>? ruleFilter = null)
    {
        var repoRoot = Environment.CurrentDirectory;
        foreach (var start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
        {
            var dir = new DirectoryInfo(Path.GetFullPath(start));
            while (dir != null)
            {
                if (Directory.Exists(Path.Combine(dir.FullName, "机械设计禁忌1000例_知识库提取")))
                {
                    repoRoot = dir.FullName;
                    dir = null;
                    break;
                }
                dir = dir.Parent;
            }
            if (Directory.Exists(Path.Combine(repoRoot, "机械设计禁忌1000例_知识库提取")))
                break;
        }
        var storePath = Path.Combine(repoRoot, "机械设计禁忌1000例_知识库提取", "diagnostic_index", storeFileName);
        if (!File.Exists(storePath))
            return [];
        var indexPath = Path.Combine(repoRoot, "机械设计禁忌1000例_知识库提取", "diagnostic_index", indexFileName);
        var retrievalSentences = LoadRuleRetrievalSentences(indexPath);

        using var doc = JsonDocument.Parse(File.ReadAllText(storePath));
        if (!doc.RootElement.TryGetProperty("rules", out var rules) || rules.ValueKind != JsonValueKind.Array)
            return [];

        var cards = new List<InspectionCard>();
        foreach (var rule in rules.EnumerateArray())
        {
            if (ruleFilter != null && !ruleFilter(rule))
                continue;
            var ruleId = ReadString(rule, "rule_id");
            if (string.IsNullOrWhiteSpace(ruleId))
                continue;
            var title = ReadString(rule, "title");
            var primaryView = ReadString(rule, "primary_view");
            var category = ReadString(rule, "knowledge_category");
            var required = new List<string>();
            var unknown = new List<string>();
            var badIf = "";
            var goodIf = "";
            if (rule.TryGetProperty("verification_rule", out var verification) && verification.ValueKind == JsonValueKind.Object)
            {
                required = ReadArray(verification, "required_model_facts")
                    .Where(item => item.ValueKind == JsonValueKind.String)
                    .Select(item => item.GetString() ?? "")
                    .Where(value => !string.IsNullOrWhiteSpace(value))
                    .ToList();
                unknown = ReadArray(verification, "unknown_if")
                    .Where(item => item.ValueKind == JsonValueKind.String)
                    .Select(item => item.GetString() ?? "")
                    .Where(value => !string.IsNullOrWhiteSpace(value))
                    .ToList();
                badIf = ReadString(verification, "bad_if");
                goodIf = ReadString(verification, "good_if");
            }

            var sourcePattern = "";
            if (rule.TryGetProperty("source_semantics", out var source) && source.ValueKind == JsonValueKind.Object)
                sourcePattern = ReadString(source, "pattern_type");
            retrievalSentences.TryGetValue(ruleId, out var ruleRetrievalSentence);
            ruleRetrievalSentence = FirstNonEmpty(ruleRetrievalSentence, title);
            var scoutRetrievalSentence = BuildScoutRetrievalSentence(title, primaryView, sourcePattern, required);
            var scopeType = InferInspectionScopeType(required, primaryView);
            var featurePatterns = InferInspectionFeaturePatterns(title, sourcePattern, required);
            var dto = new
            {
                rule_id = ruleId,
                title,
                knowledge_category = category,
                primary_view = primaryView,
                scope_type = scopeType,
                scout_retrieval_sentence = scoutRetrievalSentence,
                trigger_signature = new
                {
                    keywords = BuildKeywordHints(title, sourcePattern, required),
                    feature_patterns = featurePatterns,
                    source_pattern = sourcePattern
                },
                required_evidence = required,
                inspection_prompt = BuildInspectionPrompt(title, badIf, required),
                negative_checks = string.IsNullOrWhiteSpace(goodIf) ? Array.Empty<string>() : new[] { goodIf },
                unknown_if_missing = unknown
            };
            var searchText = $"{ruleId} {title} {category} {primaryView} {sourcePattern} {scoutRetrievalSentence} {ruleRetrievalSentence} {string.Join(' ', required)} {badIf} {goodIf} {string.Join(' ', featurePatterns)}";
            cards.Add(new InspectionCard
            {
                RuleId = ruleId,
                Title = title,
                ScopeType = scopeType,
                SearchText = searchText,
                Dto = dto
            });
        }

        return cards;
    }

    static Dictionary<string, string> LoadRuleRetrievalSentences(string indexPath)
    {
        var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        if (!File.Exists(indexPath))
            return result;

        using var doc = JsonDocument.Parse(File.ReadAllText(indexPath));
        if (!doc.RootElement.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
            return result;

        foreach (var item in items.EnumerateArray())
        {
            var ruleId = ReadString(item, "rule_id");
            if (string.IsNullOrWhiteSpace(ruleId))
                continue;
            var sentence = "";
            if (item.TryGetProperty("retrieval_unit", out var retrievalUnit) && retrievalUnit.ValueKind == JsonValueKind.Object)
                sentence = ReadString(retrievalUnit, "retrieval_sentence");
            sentence = FirstNonEmpty(sentence, ReadString(item, "retrieval_sentence"), ReadString(item, "title"));
            if (!string.IsNullOrWhiteSpace(sentence))
                result[ruleId] = sentence;
        }

        return result;
    }

    static string BuildScoutRetrievalSentence(string title, string primaryView, string sourcePattern, IReadOnlyList<string> required)
    {
        var requiredText = required.Count == 0
            ? "相关几何、功能和装配事实"
            : string.Join("、", required.Take(5));
        var focus = primaryView switch
        {
            "assembly_contact_fit" => "检查接触、配合、同轴、间隙和装配方向",
            "disassembly_maintenance_access" => "检查拆装路径、操作空间和维护可达性",
            "locating_limit_datum" => "检查定位、限位、基准和自由度约束",
            "surface_role_boundary" => "检查加工面、非加工面、接触面和功能面边界",
            "feature_pattern" => "检查孔、槽、凸台、筋板、台阶等局部特征",
            "part_local_shape" => "检查零件局部形状、壁厚、凸起和过渡结构",
            _ => "检查与该知识相关的模型事实"
        };
        return CleanInspectionText($"{focus}，重点观察{requiredText}，用于侦察“{title}”相关风险。");
    }

    static bool RuleBelongsToChapter(JsonElement rule, int chapter)
    {
        var prefix = chapter.ToString(CultureInfo.InvariantCulture) + ".";
        if (ReadString(rule, "rule_id").StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
            ReadString(rule, "code").StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        if (rule.TryGetProperty("source_semantics", out var source) &&
            source.ValueKind == JsonValueKind.Object &&
            source.TryGetProperty("chapter", out var chapterValue) &&
            chapterValue.ValueKind == JsonValueKind.Number &&
            chapterValue.TryGetInt32(out var value))
        {
            return value == chapter;
        }

        return false;
    }

    static StaticScoutScanDraft BuildStaticScoutCoarseScan(ComponentPromptPlan plan)
    {
        var signatures = new List<object>();
        var queryTerms = new List<string>
        {
            plan.ComponentId,
            plan.InstanceName,
            plan.DisplayName,
            InferShapeHint(plan)
        };

        if (!plan.IsPartDocument)
        {
            queryTerms.Add(plan.PartRole);
            queryTerms.Add(plan.FunctionalBlockId);
        }

        foreach (var seed in plan.CoverageSeeds)
        {
            signatures.Add(new
            {
                type = seed.FeatureType,
                anchor_id = seed.AnchorId,
                description = seed.InspectionFocus,
                evidence_basis = "FeatureGraph feature type and geometry summary",
                evidence_refs = seed.EvidenceRefs,
                confidence = "high"
            });
            queryTerms.Add(seed.FeatureType);
            queryTerms.Add(seed.InspectionFocus);
        }

        var assemblySignatures = new List<string>();
        if (!plan.IsPartDocument && plan.Contacts.Count > 0)
        {
            assemblySignatures.Add("direct_contact_interface");
            queryTerms.Add("direct_contact_interface contact mating support locating face");
        }
        if (!plan.IsPartDocument && plan.Mates.Count > 0)
        {
            assemblySignatures.Add("direct_mate_or_fit_interface");
            queryTerms.Add("direct_mate_or_fit_interface mate fit coaxial locating assembly");
        }
        if (!plan.IsPartDocument && plan.AdjacentComponentIds.Count > 0)
        {
            assemblySignatures.Add("has_adjacent_components");
            queryTerms.Add("adjacent component assembly interface");
        }
        if (plan.Faces.Count >= 30)
        {
            assemblySignatures.Add("multi_face_structural_part");
            queryTerms.Add("multi_face structural part casting support bracket housing");
        }

        var cardQueryText = CleanInspectionText(string.Join(" ", queryTerms.Where(value => !string.IsNullOrWhiteSpace(value))));
        var dto = new
        {
            schema = "component_static_scout_coarse_scan_v1",
            component_id = plan.ComponentId,
            instance_name = plan.InstanceName,
            usage_policy = "This coarse scan is generated only to retrieve dynamic inspection cards. It is not evidence for detailed local subgraph generation.",
            evidence_scope = "Programmatic scan over target component identity, B-rep feature/face text, direct contacts, and direct mates.",
            feature_signatures = signatures,
            assembly_signatures = assemblySignatures,
            coverage_seed_count = plan.CoverageSeeds.Count,
            card_query_text = cardQueryText,
            downstream_policy = "Detailed local subgraphs must be generated from original B-rep, images, direct contacts/mates, and selected inspection cards, not from this coarse scan."
        };

        return new StaticScoutScanDraft
        {
            Dto = dto,
            CardQueryText = cardQueryText
        };
    }

    static void AddFeatureSignature(
        List<object> signatures,
        List<string> queryTerms,
        string sourceText,
        string type,
        string triggerTerms,
        string description,
        string evidenceBasis)
    {
        var triggerTokens = triggerTerms
            .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();
        var matched = triggerTokens
            .Where(token => sourceText.Contains(token, StringComparison.OrdinalIgnoreCase))
            .Take(8)
            .ToList();
        if (matched.Count == 0)
            return;

        queryTerms.Add(type);
        queryTerms.Add(string.Join(" ", matched));
        signatures.Add(new
        {
            type,
            description,
            evidence_basis = evidenceBasis,
            matched_terms = matched,
            confidence = matched.Count >= 2 ? "medium" : "low"
        });
    }

    static string CleanInspectionText(string text) =>
        Regex.Replace(text ?? "", @"\s+", " ").Trim();

    static List<object> SelectInspectionCardsForPlan(ComponentPromptPlan plan, IReadOnlyList<InspectionCard> cards, int maxCards)
    {
        if (cards.Count == 0)
            return [];

        var query = BuildPlanInspectionQuery(plan);
        var queryTokens = TokenizeInspectionText(query);
        return cards
            .Select(card => new
            {
                Card = card,
                Score = ScoreInspectionCard(card, plan, queryTokens)
            })
            .Where(item => item.Score > 0)
            .OrderByDescending(item => item.Score)
            .ThenBy(item => item.Card.RuleId, StringComparer.OrdinalIgnoreCase)
            .Take(maxCards)
            .Select(item => new
            {
                score = item.Score,
                item.Card.Dto
            })
            .Cast<object>()
            .ToList();
    }

    static string BuildPlanInspectionQuery(ComponentPromptPlan plan)
    {
        if (!string.IsNullOrWhiteSpace(plan.CoarseScoutCardQueryText))
            return plan.CoarseScoutCardQueryText;

        return string.Join(" ", new[]
        {
            plan.ComponentId,
            plan.InstanceName,
            plan.DisplayName,
            InferShapeHint(plan),
            string.Join(" ", plan.AdjacentComponentIds),
            string.Join(" ", plan.Features.Take(12).Select(item => item.GetRawText())),
            string.Join(" ", plan.Contacts.Take(12).Select(item => item.GetRawText())),
            string.Join(" ", plan.Mates.Take(12).Select(item => item.GetRawText()))
        });
    }

    static int ScoreInspectionCard(InspectionCard card, ComponentPromptPlan plan, HashSet<string> queryTokens)
    {
        var cardTokens = TokenizeInspectionText(card.SearchText);
        var score = queryTokens.Count(cardTokens.Contains);
        var planText = $"{plan.InstanceName} {plan.DisplayName} {InferShapeHint(plan)}".ToLowerInvariant();
        var cardText = card.SearchText.ToLowerInvariant();
        if (planText.Contains("hole") && cardText.Contains("hole")) score += 8;
        if ((planText.Contains("screw") || planText.Contains("shaft")) && (cardText.Contains("coaxial") || cardText.Contains("centering") || cardText.Contains("hole") || cardText.Contains("fit"))) score += 4;
        if (planText.Contains("slider") && (cardText.Contains("sliding") || cardText.Contains("contact") || cardText.Contains("locating") || cardText.Contains("fit"))) score += 5;
        if ((planText.Contains("support") || planText.Contains("bracket") || planText.Contains("flange") || planText.Contains("link")) && (cardText.Contains("contact") || cardText.Contains("locating") || cardText.Contains("assembly") || cardText.Contains("mount"))) score += 5;
        if (plan.Mates.Count > 0 && (card.ScopeType == "interface_pair" || card.ScopeType == "functional_block")) score += 3;
        if (plan.Contacts.Count > 0 && card.SearchText.Contains("contact", StringComparison.OrdinalIgnoreCase)) score += 3;
        return score;
    }

    static HashSet<string> TokenizeInspectionText(string text)
    {
        var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (Match match in Regex.Matches(text.ToLowerInvariant(), @"[a-z0-9_]{2,}|[\u4e00-\u9fff]{2,4}"))
            tokens.Add(match.Value);
        return tokens;
    }

    static string InferInspectionScopeType(IReadOnlyList<string> required, string primaryView)
    {
        var text = $"{primaryView} {string.Join(' ', required)}".ToLowerInvariant();
        if (text.Contains("cross_part") || text.Contains("multi_part") || text.Contains("mating") || text.Contains("fit") || text.Contains("contact") || text.Contains("assembly"))
            return "interface_pair";
        if (text.Contains("tool") || text.Contains("access") || text.Contains("clamping") || text.Contains("fixture"))
            return "whole_part";
        if (text.Contains("batch") || text.Contains("part_grouping") || text.Contains("functional_dependency") || text.Contains("tolerance_chain"))
            return "functional_block";
        return "local_feature";
    }

    static List<string> InferInspectionFeaturePatterns(string title, string sourcePattern, IReadOnlyList<string> required)
    {
        var text = $"{title} {sourcePattern} {string.Join(' ', required)}".ToLowerInvariant();
        var values = new List<string>();
        if (text.Contains("hole")) values.Add("hole_or_hole_group");
        if (text.Contains("face") || text.Contains("surface") || text.Contains("plane")) values.Add("machined_or_mating_face");
        if (text.Contains("slot") || text.Contains("groove")) values.Add("slot_or_groove");
        if (text.Contains("cylind")) values.Add("cylindrical_surface");
        if (text.Contains("flange") || text.Contains("boss")) values.Add("flange_or_non_axisymmetric_boundary");
        if (text.Contains("tool") || text.Contains("drill") || text.Contains("cutter")) values.Add("tool_access_or_drilling");
        if (values.Count == 0) values.Add("general_mechanical_feature");
        return values.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
    }

    static List<string> BuildKeywordHints(string title, string sourcePattern, IReadOnlyList<string> required)
    {
        return TokenizeInspectionText($"{title} {sourcePattern} {string.Join(' ', required)}")
            .OrderBy(token => token, StringComparer.OrdinalIgnoreCase)
            .Take(18)
            .ToList();
    }

    static string BuildInspectionPrompt(string title, string badIf, IReadOnlyList<string> required)
    {
        if (!string.IsNullOrWhiteSpace(badIf))
            return $"Check local structure around '{title}'. Verify required evidence first: {string.Join(", ", required.Take(8))}. Before judgement, compare against: {badIf}";
        return $"Check local structure around '{title}'. Verify required evidence first: {string.Join(", ", required.Take(8))}.";
    }

    static string InferRelationHint(ComponentPromptPlan plan, string neighborId)
    {
        var relationText = string.Join(" ", plan.Contacts.Concat(plan.Mates).Select(item => item.GetRawText()));
        if (relationText.Contains("coax", StringComparison.OrdinalIgnoreCase) || relationText.Contains("concentric", StringComparison.OrdinalIgnoreCase))
            return "coaxial_or_concentric_interface";
        if (relationText.Contains("骞抽潰", StringComparison.OrdinalIgnoreCase) || relationText.Contains("planar", StringComparison.OrdinalIgnoreCase))
            return "planar_contact_or_mate";
        return plan.Mates.Count > 0 ? "direct_mate_or_fit" : "direct_contact_or_adjacency";
    }

    static string InferShapeHint(ComponentPromptPlan plan)
    {
        var text = $"{plan.InstanceName} {plan.DisplayName} {string.Join(' ', plan.Features.Take(8).Select(f => f.GetRawText()))}".ToLowerInvariant();
        var hints = new List<string>();
        if (text.Contains("hole")) hints.Add("hole_features");
        if (text.Contains("screw") || text.Contains("shaft")) hints.Add("shaft_or_screw_like");
        if (text.Contains("slider")) hints.Add("slider_like");
        if (text.Contains("support") || text.Contains("bracket") || text.Contains("flange")) hints.Add("bracket_or_flange_like");
        if (text.Contains("joint") || text.Contains("cap") || text.Contains("spherical")) hints.Add("joint_cap_or_spherical_interface");
        if (hints.Count == 0 && plan.Faces.Count >= 30) hints.Add("multi_face_structural_part");
        if (hints.Count == 0) hints.Add("general_part");
        return string.Join(", ", hints.Distinct(StringComparer.OrdinalIgnoreCase));
    }

    static Dictionary<string, JsonElement> LoadPartCheckEvidenceByComponent(string outputDir)
    {
        var manifestPath = Path.Combine(outputDir, "part_check_extractions", "manifest.json");
        if (!File.Exists(manifestPath))
            return new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);

        using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
        if (!doc.RootElement.TryGetProperty("components", out var components) ||
            components.ValueKind != JsonValueKind.Array)
        {
            return new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
        }

        return components.EnumerateArray()
            .Where(item => item.ValueKind == JsonValueKind.Object)
            .Select(item => new { Id = ReadString(item, "component_id"), Value = item.Clone() })
            .Where(item => !string.IsNullOrWhiteSpace(item.Id))
            .GroupBy(item => item.Id, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(group => group.Key, group => group.First().Value, StringComparer.OrdinalIgnoreCase);
    }

    static List<JsonElement> BuildPartCheckImageEvidenceRefs(string outputDir, JsonElement partCheckEvidence)
    {
        var result = new List<JsonElement>();
        if (!ReadString(partCheckEvidence, "status").Equals("ok", StringComparison.OrdinalIgnoreCase))
            return result;
        if (!partCheckEvidence.TryGetProperty("image_views", out var views) || views.ValueKind != JsonValueKind.Array)
            return result;

        foreach (var view in views.EnumerateArray())
        {
            var imageRef = BuildImageEvidenceRef(outputDir, view);
            var node = JsonSerializer.SerializeToNode(imageRef, JsonOptions.Default) as JsonObject;
            if (node == null)
            {
                result.Add(imageRef);
                continue;
            }

            node["evidence_source"] = "reused_initial_assembly_extraction";
            node["path_policy"] = "logical part-check evidence is separated under part_check_extractions, while the image file is reused from the initial extraction output";
            result.Add(JsonSerializer.SerializeToElement(node, JsonOptions.Default));
        }

        return result;
    }

    static List<ComponentPromptPlan> BuildComponentPromptPlans(string structureGraphPath, string outputDir)
    {
        using var doc = JsonDocument.Parse(File.ReadAllText(structureGraphPath));
        var root = doc.RootElement;
        if (!TryGetPath(root, out var primary, "SemanticFusionEvidence", "primary"))
            return [];
        var productDescription = TryGetPath(root, out var userContext, "UserContext")
            ? ReadString(userContext, "product_description")
            : "";
        var purchasedHints = TryGetPath(root, out userContext, "UserContext")
            ? ReadStringArrayValue(userContext, "purchased_component_hints")
            : [];
        var userPartFunctionProfile = TryGetPath(root, out userContext, "UserContext")
            ? ReadString(userContext, "part_function_profile")
            : "";
        var documentKind = FirstNonEmpty(ReadString(primary, "DocumentKind"), Path.GetExtension(ReadString(primary, "Path")).Equals(".SLDPRT", StringComparison.OrdinalIgnoreCase) ? "part" : "");

        string componentEvidenceDir = Path.Combine(outputDir, "component_evidence");
        Directory.CreateDirectory(componentEvidenceDir);

        var components = ReadArray(primary, "AssemblyComponents");
        var packages = ReadArray(primary, "ComponentEvidencePackages");
        var partImagePlans = ReadArray(primary, "PartImagePlan");
        var contextPlans = ReadArray(primary, "ComponentContextImagePlan");
        var contacts = ReadArray(primary, "AssemblyFaceContacts");
        var mates = ReadArray(primary, "AssemblyMates");
        var faces = ReadArray(primary, "AssemblyFaces");
        var imageViews = TryGetPath(primary, out var imageExport, "ImageExport")
            ? ReadArray(imageExport, "Views")
            : [];
        var partCheckEvidenceByComponent = LoadPartCheckEvidenceByComponent(outputDir);
        var features = TryGetPath(primary, out var featureGraph, "FeatureGraph")
            ? ReadArray(featureGraph, "Features")
            : [];

        var componentsById = components
            .Select(c => new { Id = ReadString(c, "Id"), Value = c })
            .Where(x => !string.IsNullOrWhiteSpace(x.Id))
            .ToDictionary(x => x.Id, x => x.Value.Clone(), StringComparer.OrdinalIgnoreCase);
        var componentsByInstance = components
            .Select(c => new { InstanceName = ReadString(c, "InstanceName"), Value = c })
            .Where(x => !string.IsNullOrWhiteSpace(x.InstanceName))
            .GroupBy(x => x.InstanceName, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.First().Value.Clone(), StringComparer.OrdinalIgnoreCase);
        var packagesById = packages
            .Select(p => new { Id = ReadString(p, "ComponentId"), Value = p })
            .Where(x => !string.IsNullOrWhiteSpace(x.Id))
            .ToDictionary(x => x.Id, x => x.Value.Clone(), StringComparer.OrdinalIgnoreCase);
        var partPlansByComponentId = partImagePlans
            .Select(p => new { Id = ReadString(p, "ComponentId"), Value = p })
            .Where(x => !string.IsNullOrWhiteSpace(x.Id))
            .ToDictionary(x => x.Id, x => x.Value.Clone(), StringComparer.OrdinalIgnoreCase);

        var plans = new List<ComponentPromptPlan>();
        foreach (var package in packages.Where(p => ReadString(p, "ReviewScope").Equals("part_design_required", StringComparison.OrdinalIgnoreCase)))
        {
            var componentId = ReadString(package, "ComponentId");
            var instanceName = ReadString(package, "InstanceName");
            var displayName = FirstNonEmpty(ReadString(package, "DisplayName"), instanceName, componentId);
            componentsById.TryGetValue(componentId, out var component);
            partPlansByComponentId.TryGetValue(componentId, out var partPlan);

            var featureIds = ReadStringArrayValue(package, "FeatureIds").ToHashSet(StringComparer.OrdinalIgnoreCase);
            var faceRefs = ReadStringArrayValue(package, "FaceRefs").ToHashSet(StringComparer.OrdinalIgnoreCase);
            var contactIds = ReadStringArrayValue(package, "ContactIds").ToHashSet(StringComparer.OrdinalIgnoreCase);
            var mateIds = ReadStringArrayValue(package, "MateIds").ToHashSet(StringComparer.OrdinalIgnoreCase);

            var ownedFeatures = features
                .Where(f => featureIds.Contains(ReadString(f, "Id")) || featureIds.Count == 0 && SameComponentByNameAndPath(package, ReadString(f, "ComponentName"), ReadString(f, "ComponentPath")))
                .Select(f => f.Clone())
                .ToList();
            var ownedFaces = faces
                .Where(f => faceRefs.Contains(FaceEvidenceRef(f)) || faceRefs.Count == 0 && SameComponentByNameAndPath(package, ReadString(f, "ComponentName"), ReadString(f, "ComponentPath")))
                .Select(f => f.Clone())
                .ToList();
            var ownedContacts = contacts
                .Where(c => contactIds.Contains(ReadString(c, "Id")) || contactIds.Count == 0 && ComponentTouchesContact(package, c))
                .Select(c => c.Clone())
                .ToList();
            var ownedMates = mates
                .Where(m => mateIds.Contains(ReadString(m, "Id")) || mateIds.Count == 0 && ComponentTouchesMate(package, m))
                .Select(m => m.Clone())
                .ToList();
            var neighborIds = ResolveAdjacentComponentIds(package, ownedContacts, ownedMates, componentsByInstance);
            var neighborPackages = neighborIds
                .Where(packagesById.ContainsKey)
                .Select(id => packagesById[id])
                .ToList();
            var neighborComponents = neighborIds
                .Where(componentsById.ContainsKey)
                .Select(id => componentsById[id])
                .ToList();
            var neighborPartPlans = neighborIds
                .Where(partPlansByComponentId.ContainsKey)
                .Select(id => partPlansByComponentId[id])
                .ToList();
            partCheckEvidenceByComponent.TryGetValue(componentId, out var partCheckEvidence);
            var availableImages = imageViews
                .Where(v => ImageBelongsToComponent(v, componentId, package))
                .Select(v => BuildImageEvidenceRef(outputDir, v))
                .ToList();
            if (partCheckEvidence.ValueKind == JsonValueKind.Object)
                availableImages.AddRange(BuildPartCheckImageEvidenceRefs(outputDir, partCheckEvidence));

            var plan = new ComponentPromptPlan
            {
                ComponentId = componentId,
                InstanceName = instanceName,
                DisplayName = displayName,
                DocumentKind = documentKind,
                Component = component.ValueKind == JsonValueKind.Undefined ? package.Clone() : component.Clone(),
                Package = package.Clone(),
                PartImagePlan = partPlan.ValueKind == JsonValueKind.Undefined ? JsonSerializer.SerializeToElement(new { missing = true }) : partPlan.Clone(),
                Features = ownedFeatures,
                Faces = ownedFaces,
                Contacts = ownedContacts,
                Mates = ownedMates,
                ContextImagePlans = contextPlans.Where(p => ReadString(p, "TargetComponentId").Equals(componentId, StringComparison.OrdinalIgnoreCase)).Select(p => p.Clone()).ToList(),
                PartCheckEvidence = partCheckEvidence.ValueKind == JsonValueKind.Object ? partCheckEvidence.Clone() : JsonSerializer.SerializeToElement(new { status = "missing" }),
                AvailableImages = availableImages,
                AdjacentComponentIds = neighborIds,
                NeighborSummaries = neighborComponents.Select(BuildNeighborSummary).ToList(),
                NeighborPackages = neighborPackages,
                NeighborComponents = neighborComponents,
                NeighborPartImagePlans = neighborPartPlans,
                ProductDescription = productDescription,
                UserPartFunctionProfile = userPartFunctionProfile,
                PurchasedComponentHints = purchasedHints
            };
            plan.CoverageSeeds = BuildCoverageSeeds(plan);
            plan.EvidenceDirectory = Path.Combine(componentEvidenceDir, SanitizeFileName(componentId));
            plan.EvidencePath = Path.Combine(plan.EvidenceDirectory, "prompt_input_round1.json");
            WriteComponentEvidenceDirectory(outputDir, plan);
            WriteComponentPromptPayloadFile(plan, includeNeighborDetails: false, requestedNeighborIds: [], "prompt_input_round1.json");
            plans.Add(plan);
        }

        return plans
            .OrderBy(p => p.InstanceName, StringComparer.OrdinalIgnoreCase)
            .ThenBy(p => p.ComponentId, StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    static List<CoverageSeedDraft> BuildCoverageSeeds(ComponentPromptPlan plan)
    {
        var seeds = new List<CoverageSeedDraft>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var feature in plan.Features)
        {
            var id = ReadString(feature, "Id");
            var type = ReadString(feature, "Type");
            if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type))
                continue;

            var focus = InspectionFocusForFeatureType(type);
            if (string.IsNullOrWhiteSpace(focus))
                continue;
            if (!seen.Add(id))
                continue;

            seeds.Add(new CoverageSeedDraft
            {
                SeedId = $"seed_{seeds.Count + 1:000}",
                AnchorType = "feature",
                AnchorId = id,
                FeatureType = type,
                PrimaryView = PrimaryViewForFeatureType(type),
                InspectionFocus = focus,
                EvidenceRefs = ReadStringArrayValue(feature, "FaceRefs"),
                GeometrySummary = SummarizeFeatureGeometry(feature)
            });
        }

        if (seeds.Count == 0)
        {
            seeds.Add(new CoverageSeedDraft
            {
                SeedId = "seed_001",
                AnchorType = "part",
                AnchorId = plan.ComponentId,
                FeatureType = "whole_part",
                PrimaryView = "part_local_shape",
                InspectionFocus = "whole part shape and visible local manufacturing candidates",
                EvidenceRefs = [],
                GeometrySummary = $"features={plan.Features.Count}; faces={plan.Faces.Count}; images={plan.AvailableImages.Count}"
            });
        }

        return seeds.Take(80).ToList();
    }

    static string InspectionFocusForFeatureType(string type)
    {
        var text = type.ToLowerInvariant();
        if (text.Contains("hole"))
            return "hole or hole group: diameter, axis, entrance face, through or blind state, spacing, nearby planes, and machining/access evidence";
        if (text.Contains("cylindrical"))
            return "cylindrical surface: diameter, axis, length, internal or external role, coaxial grouping, and machining/access evidence";
        if (text.Contains("unknown") || text.Contains("complex") || text.Contains("transition"))
            return "complex or unknown local surface: highlighted face identity, transition shape, partial bore or chamfer possibility, adjacent boundaries, and tool-access risk evidence";
        if (text.Contains("planar") || text.Contains("surface_candidate"))
            return "planar surface candidate: area, neighboring faces, step boundary, hole entry relation, and machining boundary evidence";
        if (text.Contains("slot") || text.Contains("groove"))
            return "slot or groove: width, depth, end shape, cutter/access direction, and stress concentration evidence";
        if (text.Contains("boss") || text.Contains("rib") || text.Contains("thin_wall") || text.Contains("lug") || text.Contains("pad"))
            return "local protrusion or thin-wall structure: thickness, transition, support role, and manufacturability evidence";
        if (text.Contains("step") || text.Contains("shoulder"))
            return "step or shoulder: height, boundary clarity, adjacent surface roles, and machining scope evidence";
        return "";
    }

    static string PrimaryViewForFeatureType(string type)
    {
        var text = type.ToLowerInvariant();
        if (text.Contains("unknown") || text.Contains("complex") || text.Contains("transition"))
            return "part_local_shape";
        if (text.Contains("planar") || text.Contains("surface_candidate") || text.Contains("cylindrical"))
            return "surface_role_boundary";
        if (text.Contains("slot") || text.Contains("groove") || text.Contains("hole") || text.Contains("boss") || text.Contains("rib") || text.Contains("thin_wall"))
            return "feature_pattern";
        return "part_local_shape";
    }

    static string SummarizeFeatureGeometry(JsonElement feature)
    {
        if (!feature.TryGetProperty("Geometry", out var geometry) || geometry.ValueKind != JsonValueKind.Object)
            return "";

        var values = new List<string>();
        foreach (var property in geometry.EnumerateObject())
        {
            var value = property.Value;
            if (value.ValueKind == JsonValueKind.Number)
                values.Add($"{property.Name}={value.GetRawText()}");
            else if (value.ValueKind == JsonValueKind.String)
                values.Add($"{property.Name}={value.GetString()}");
            else if (value.ValueKind == JsonValueKind.Array)
                values.Add($"{property.Name}={CompactJson(value, maxLength: 80)}");
            if (values.Count >= 10)
                break;
        }

        return string.Join("; ", values);
    }

    static string CompactJson(JsonElement element, int maxLength)
    {
        var text = Regex.Replace(element.GetRawText(), @"\s+", "");
        return text.Length <= maxLength ? text : text[..maxLength] + "...";
    }

    static void WriteComponentEvidenceDirectory(string outputDir, ComponentPromptPlan plan)
    {
        Directory.CreateDirectory(plan.EvidenceDirectory);
        var neighborsDir = Path.Combine(plan.EvidenceDirectory, "neighbors");
        Directory.CreateDirectory(neighborsDir);

        WriteJsonFile(Path.Combine(plan.EvidenceDirectory, "target_component.json"), new
        {
            schema = "component_target_identity_v1",
            component_id = plan.ComponentId,
            instance_name = plan.InstanceName,
            display_name = plan.DisplayName,
            assembly_component = plan.Component,
            component_brep_package = plan.Package
        });

        WriteJsonFile(Path.Combine(plan.EvidenceDirectory, "target_brep.json"), new
        {
            schema = "component_target_brep_v1",
            component_id = plan.ComponentId,
            selection_policy = "Only features and faces owned by this exact component instance are included.",
            features = plan.Features,
            faces = plan.Faces
        });

        WriteJsonFile(Path.Combine(plan.EvidenceDirectory, "assembly_relations.json"), new
        {
            schema = "component_direct_assembly_relations_v1",
            component_id = plan.ComponentId,
            selection_policy = "Only direct contacts and mates where this component participates are included.",
            adjacent_component_ids = plan.AdjacentComponentIds,
            direct_contact_relations = plan.Contacts,
            direct_mate_relations = plan.Mates,
            adjacent_component_summaries = plan.NeighborSummaries
        });

        WriteJsonFile(Path.Combine(plan.EvidenceDirectory, "functional_group_context.json"), new
        {
            schema = "component_functional_group_context_file_v1",
            component_id = plan.ComponentId,
            selection_policy = "Only this component's functional groups and relation-scoped interface faces are included.",
            functional_group_context = plan.FunctionalGroupContext
        });

        WriteJsonFile(Path.Combine(plan.EvidenceDirectory, "images.json"), new
        {
            schema = "component_image_evidence_v1",
            component_id = plan.ComponentId,
            selection_policy = "Post-functional-group part-level checking uses only all-face highlight images generated under functional_group_evidence/<group_id>/all_face_highlights. Initial extraction images and part_check reused images are excluded.",
            target_part_image_plan = new
            {
                excluded = true,
                reason = "Initial extraction or direct part image plans are not allowed in the post-functional-group part-level checking stage."
            },
            target_part_check_evidence = BuildPartCheckEvidenceWithoutOldImages(plan.PartCheckEvidence),
            target_context_image_plans = Array.Empty<object>(),
            available_target_images = plan.AvailableImages
        });

        var requestedNeighborIds = plan.AdjacentComponentIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
        foreach (var neighborId in requestedNeighborIds)
        {
            var neighborComponent = plan.NeighborComponents.FirstOrDefault(c => ReadString(c, "Id").Equals(neighborId, StringComparison.OrdinalIgnoreCase));
            var neighborPackage = plan.NeighborPackages.FirstOrDefault(p => ReadString(p, "ComponentId").Equals(neighborId, StringComparison.OrdinalIgnoreCase));
            var neighborPartImagePlan = plan.NeighborPartImagePlans.FirstOrDefault(p => ReadString(p, "ComponentId").Equals(neighborId, StringComparison.OrdinalIgnoreCase));
            var relevantContacts = plan.Contacts.Where(r => RelationTouchesComponent(r, neighborPackage, neighborComponent)).Select(r => r.Clone()).ToList();
            var relevantMates = plan.Mates.Where(r => RelationTouchesComponent(r, neighborPackage, neighborComponent)).Select(r => r.Clone()).ToList();
            var neighborFaceRefs = relevantContacts
                .Concat(relevantMates)
                .SelectMany(r => ReadNeighborRelationFaceRefs(r, neighborPackage, neighborComponent))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
                .ToList();
            WriteJsonFile(Path.Combine(neighborsDir, $"{SanitizeFileName(neighborId)}.json"), new
            {
                schema = "component_neighbor_detail_v1",
                target_component_id = plan.ComponentId,
                neighbor_component_id = neighborId,
                usage_policy = "Do not include this neighbor detail in the first prompt. Load it only when neighbor_detail_request explicitly asks for this component.",
                selection_policy = "Relation-scoped neighbor evidence only. Full neighbor B-rep package is intentionally excluded.",
                neighbor_summary = neighborComponent.ValueKind == JsonValueKind.Undefined ? null : (object?)BuildNeighborSummary(neighborComponent),
                relevant_contact_relations = relevantContacts,
                relevant_mate_relations = relevantMates,
                relevant_neighbor_face_refs = neighborFaceRefs,
                neighbor_image_plan_ref = BuildNeighborImagePlanRef(neighborPartImagePlan),
                omitted_evidence = new[]
                {
                    "full_neighbor_component_brep_package",
                    "unrelated_neighbor_features",
                    "unrelated_neighbor_faces",
                    "unrelated_neighbor_images"
                }
            });
        }

        WriteJsonFile(Path.Combine(plan.EvidenceDirectory, "manifest.json"), new
        {
            schema = "component_evidence_manifest_v1",
            component_id = plan.ComponentId,
            instance_name = plan.InstanceName,
            display_name = plan.DisplayName,
            evidence_directory = Path.GetRelativePath(outputDir, plan.EvidenceDirectory).Replace('\\', '/'),
            first_round_prompt_input = Path.GetRelativePath(outputDir, plan.EvidencePath).Replace('\\', '/'),
            files = new
            {
                target_component = "target_component.json",
                target_brep = "target_brep.json",
                assembly_relations = "assembly_relations.json",
                functional_group_context = "functional_group_context.json",
                component_function_profile = "component_function_profile.json",
                images = "images.json",
                neighbor_details_directory = "neighbors/"
            },
            counts = new
            {
                features = plan.Features.Count,
                faces = plan.Faces.Count,
                direct_contacts = plan.Contacts.Count,
                direct_mates = plan.Mates.Count,
                functional_groups = CountFunctionalGroups(plan),
                target_images = plan.AvailableImages.Count,
                adjacent_components = plan.AdjacentComponentIds.Count
            },
            isolation_policy = new[]
            {
                "First-round component analysis may use only target_component.json, target_brep.json, assembly_relations.json, functional_group_context.json, images.json, and adjacent summaries.",
                "Functional-group analysis may use functional_group_context.json, which contains only relation-scoped interface faces and mates/contacts.",
                "Neighbor detail files under neighbors/ are excluded unless the AI explicitly requests them through neighbor_detail_request.",
                "No global ComponentEvidencePackages, global faces, global features, or unrelated component images are included."
            }
        });

        WriteJsonFile(Path.Combine(plan.EvidenceDirectory, "component_function_profile.json"), ComponentFunctionProfileForPrompt(plan));
    }

    static int CountFunctionalGroups(ComponentPromptPlan plan) =>
        plan.FunctionalGroupContext["groups"] is JsonArray groups ? groups.Count : 0;

    static void WriteComponentPromptPayloadFile(ComponentPromptPlan plan, bool includeNeighborDetails, IReadOnlyList<string> requestedNeighborIds, string fileName)
    {
        Directory.CreateDirectory(plan.EvidenceDirectory);
        var path = Path.Combine(plan.EvidenceDirectory, fileName);
        File.WriteAllText(path, JsonSerializer.Serialize(BuildComponentPromptPayload(plan, includeNeighborDetails, requestedNeighborIds), JsonOptions.Default), new UTF8Encoding(false));
    }

    static void WriteJsonFile(string path, object value)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
        File.WriteAllText(path, JsonSerializer.Serialize(value, JsonOptions.Default), new UTF8Encoding(false));
    }

    static object BuildComponentPromptPayload(ComponentPromptPlan plan, bool includeNeighborDetails, IReadOnlyList<string> requestedNeighborIds)
    {
        var requested = requestedNeighborIds.Count > 0
            ? requestedNeighborIds.ToHashSet(StringComparer.OrdinalIgnoreCase)
            : plan.AdjacentComponentIds.ToHashSet(StringComparer.OrdinalIgnoreCase);

        return new
        {
            schema = "component_local_subgraph_prompt_input_v1",
            diagnostic_stage = plan.DiagnosticStage,
            knowledge_scope = plan.KnowledgeScope,
            flow_boundary = plan.IsPartDocument
                ? "Single-part checking: only part/feature units are allowed and only the part-design knowledge library is used."
                : "Assembly group-internal part checking: functional-group checking has already run; only part/feature units are allowed and only the part-design knowledge library is used.",
            evidence_files = new
            {
                directory = plan.EvidenceDirectory,
                manifest = Path.Combine(plan.EvidenceDirectory, "manifest.json"),
                target_component = Path.Combine(plan.EvidenceDirectory, "target_component.json"),
                target_brep = Path.Combine(plan.EvidenceDirectory, "target_brep.json"),
                assembly_relations = Path.Combine(plan.EvidenceDirectory, "assembly_relations.json"),
                functional_group_context = Path.Combine(plan.EvidenceDirectory, "functional_group_context.json"),
                component_function_profile = Path.Combine(plan.EvidenceDirectory, "component_function_profile.json"),
                images = Path.Combine(plan.EvidenceDirectory, "images.json"),
                neighbor_details_directory = Path.Combine(plan.EvidenceDirectory, "neighbors"),
                included_neighbor_detail_files = includeNeighborDetails
                    ? requested.Select(id => Path.Combine(plan.EvidenceDirectory, "neighbors", $"{SanitizeFileName(id)}.json")).ToList()
                    : []
            },
            component_function_profile_input = ComponentFunctionProfileForPrompt(plan),
            diagnostic_context = new
            {
                policy = "Function-block context and selected inspection cards guide local evidence extraction only. They are not direct violation judgements.",
                user_product_description = plan.ProductDescription,
                user_purchased_component_hints = plan.PurchasedComponentHints,
                functional_block = plan.FunctionalBlockContext,
                part_function_profile = plan.PartFunctionProfile,
                precomputed_component_function_profile = plan.AssemblyFunctionProfile,
                functional_group_context = plan.FunctionalGroupContext,
                matched_inspection_cards = plan.MatchedInspectionCards
            },
            evidence_policy = new
            {
                selection = "exact_by_component_instance_and_explicit_ids",
                truncation = "none",
                storage = "component-scoped evidence directory",
                target_face_selection = "ComponentEvidencePackage.FaceRefs matched against AssemblyFaces as '<ComponentName>:face#<FaceIndex>'; falls back to same component only when FaceRefs is empty.",
                target_feature_selection = "ComponentEvidencePackage.FeatureIds matched against FeatureGraph.Features; falls back to same component only when FeatureIds is empty.",
                target_relation_selection = "ComponentEvidencePackage.ContactIds/MateIds matched exactly; falls back to direct same-component relation scan only when ids are empty.",
                functional_group_selection = "Functional-group diagnostics are generated before this component prompt. Here functional_group_context is read-only background context and must not produce functional_group/interface units.",
                part_check_extraction = "Part-level checking may use component-local B-rep faces/features from the initial extraction, but image evidence is restricted to functional_group_all_face_highlight images generated after functional-group split. Do not use initial extraction images or part_check reused images.",
                neighbor_first_round = "summary_only",
                neighbor_enrichment = "only when AI returns neighbor_detail_request.needs_neighbor_detail=true"
            },
            counts = new
            {
                target_features = plan.Features.Count,
                target_faces = plan.Faces.Count,
                direct_contacts = plan.Contacts.Count,
                direct_mates = plan.Mates.Count,
                functional_groups = CountFunctionalGroups(plan),
                available_target_images = plan.AvailableImages.Count,
                adjacent_components = plan.AdjacentComponentIds.Count
            },
            feature_batch = new
            {
                batch_id = plan.BatchId,
                batch_index = plan.BatchIndex,
                batch_count = plan.BatchCount,
                batch_feature_ids = plan.BatchFeatureIds,
                coverage_policy = "Mandatory. Every target_features item in this batch must produce one local_semantic_units item and one linked local_subgraphs item."
            },
            task = "Generate part/feature-level diagnostic subgraphs directly for one component instance after functional-group checking has completed. Do not generate a global mechanical semantic graph or functional-group/interface units.",
            output_contract = new
            {
                diagnostic_stage = plan.DiagnosticStage,
                knowledge_scope = plan.KnowledgeScope,
                allowed_anchor_types = new[] { "part", "feature" },
                forbidden_anchor_types = new[] { "interface", "functional_group" },
                downstream_knowledge_library = "part-design knowledge library only"
            },
            target_component = plan.Component,
            target_component_brep_package = plan.Package,
            functional_group_context = plan.FunctionalGroupContext,
            target_part_check_evidence = BuildPartCheckEvidenceWithoutOldImages(plan.PartCheckEvidence),
            target_features = plan.Features,
            target_faces = plan.Faces,
            target_part_image_plan = new
            {
                excluded = true,
                reason = "Use only functional_group_all_face_highlight images in this post-functional-group part-level stage."
            },
            target_context_image_plans = Array.Empty<object>(),
            available_target_images = plan.AvailableImages,
            direct_contact_relations = plan.Contacts,
            direct_mate_relations = plan.Mates,
            adjacent_component_summaries = plan.NeighborSummaries,
            neighbor_detail_policy = new
            {
                first_round_rule = "Use only target component B-rep, functional_group_all_face_highlight images, direct contacts/mates as part context, and adjacent summaries.",
                must_ask_each_round = "Always output neighbor_detail_request. If a subgraph depends on neighbor geometry, set needs_neighbor_detail=true and list required component_ids.",
                allowed_reasons = new[] { "mating face geometry", "motion pair", "occlusion/access path", "installation clearance", "coaxial relation", "support/locating relation" }
            },
            neighbor_details = includeNeighborDetails
                ? new
                {
                    included_component_ids = requested,
                    selection_policy = "Only relation-scoped neighbor evidence is included. Full neighbor B-rep packages are excluded.",
                    neighbor_evidence_files = requested
                        .Select(id => Path.Combine(plan.EvidenceDirectory, "neighbors", $"{SanitizeFileName(id)}.json"))
                        .ToList(),
                    relation_scoped_neighbor_evidence = BuildRequestedNeighborDetails(plan, requested)
                }
                : null
        };
    }

    static JsonElement BuildPartCheckEvidenceWithoutOldImages(JsonElement partCheckEvidence)
    {
        if (partCheckEvidence.ValueKind != JsonValueKind.Object)
            return partCheckEvidence.ValueKind == JsonValueKind.Undefined
                ? JsonSerializer.SerializeToElement(new { status = "missing" }, JsonOptions.Default)
                : partCheckEvidence.Clone();

        var node = JsonSerializer.SerializeToNode(partCheckEvidence, JsonOptions.Default) as JsonObject ?? new JsonObject();
        node.Remove("image_views");
        node.Remove("image_refs");
        node.Remove("images");
        node["image_policy"] = "All initial extraction images and part_check reused images are excluded from the post-functional-group part-level checking stage. Use available_target_images with image_kind=functional_group_all_face_highlight only.";
        return JsonSerializer.SerializeToElement(node, JsonOptions.Default);
    }

    static List<object> BuildRequestedNeighborDetails(ComponentPromptPlan plan, IReadOnlySet<string> requested)
    {
        var details = new List<object>();
        foreach (var neighborId in requested.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
        {
            var neighborComponent = plan.NeighborComponents.FirstOrDefault(c => ReadString(c, "Id").Equals(neighborId, StringComparison.OrdinalIgnoreCase));
            var neighborPackage = plan.NeighborPackages.FirstOrDefault(p => ReadString(p, "ComponentId").Equals(neighborId, StringComparison.OrdinalIgnoreCase));
            var neighborPartImagePlan = plan.NeighborPartImagePlans.FirstOrDefault(p => ReadString(p, "ComponentId").Equals(neighborId, StringComparison.OrdinalIgnoreCase));
            var relevantContacts = plan.Contacts.Where(r => RelationTouchesComponent(r, neighborPackage, neighborComponent)).Select(r => r.Clone()).ToList();
            var relevantMates = plan.Mates.Where(r => RelationTouchesComponent(r, neighborPackage, neighborComponent)).Select(r => r.Clone()).ToList();
            var neighborFaceRefs = relevantContacts
                .Concat(relevantMates)
                .SelectMany(r => ReadNeighborRelationFaceRefs(r, neighborPackage, neighborComponent))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
                .ToList();

            details.Add(new
            {
                neighbor_component_id = neighborId,
                selection_policy = "Relation-scoped neighbor evidence only.",
                neighbor_summary = neighborComponent.ValueKind == JsonValueKind.Undefined ? null : (object?)BuildNeighborSummary(neighborComponent),
                relevant_contact_relations = relevantContacts,
                relevant_mate_relations = relevantMates,
                relevant_neighbor_face_refs = neighborFaceRefs,
                neighbor_image_plan_ref = BuildNeighborImagePlanRef(neighborPartImagePlan),
                omitted_evidence = new[]
                {
                    "full_neighbor_component_brep_package",
                    "unrelated_neighbor_features",
                    "unrelated_neighbor_faces",
                    "unrelated_neighbor_images"
                }
            });
        }

        return details;
    }

    static string BuildComponentLocalSubgraphPrompt(ComponentPromptPlan plan, bool includeNeighborDetails, IReadOnlyList<string> requestedNeighborIds)
    {
        var payload = BuildComponentPromptPayload(plan, includeNeighborDetails, requestedNeighborIds);
        var json = JsonSerializer.Serialize(payload, JsonOptions.Default);
        var documentPolicy = plan.IsPartDocument
            ? "This is a single-part input. There is no assembly functional-positioning stage; component_function_profile_input is the user-supplied part function portrait and must be used in this call."
            : "This is an assembly component input. component_function_profile_input is the precomputed assembly component function portrait and must be used in this call. Functional-block context is low-resolution background only and must not override target B-rep/images/direct relations.";
        var functionalGroupPolicy = plan.IsPartDocument
            ? "Single-part input: do not create functional_group units."
            : "Assembly component part-level stage: functional-group checking has already been generated before this prompt. Do not create functional_group or interface local_semantic_units here; generate only part and feature units for this component.";

        return $$"""
You are a mechanical design diagnostic expert.
Analyze exactly one component instance and directly generate local subgraphs for retrieval. Answer in Chinese.

Hard rules:
- Do not output or summarize a global mechanical semantic graph.
- {{documentPolicy}}
- Before analyzing any face or feature, read component_function_profile_input. Every local unit must be consistent with this function portrait, and every fact_bundle must include it as assembly_function_context or part_function_context.
- Assembly diagnostics are sequential: functional-group design checking has already run before this component prompt; this prompt is only for the later group-internal part/feature-level checking stage.
- {{functionalGroupPolicy}}
- Generate multiple local_subgraphs/local_semantic_units for this component when its part shape, features, interfaces, or functional group evidence support different checks.
- Keep every unit bound to component_id `{{plan.ComponentId}}` unless it is explicitly an interface unit with a direct contact/mate relation.
- Use the target component B-rep package, functional_group_all_face_highlight images only, position in assembly, and direct contact/mate relations only as context for part/feature-level checking.
- functional_group_context contains only design-relevant interface faces and relations: explicit SolidWorks mates plus B-rep-derived contacts, flush/coincident planar faces, coaxial cylindrical fits, near-zero-gap or overlapping face pairs. Use these as first-class functional-group evidence even without explicit mates.
- Do not use functional_group_context.groups[].detailed_function_description or matched_functional_group_inspection_cards to create new functional-group/interface units in this stage.
- Use target_part_check_evidence as non-image B-rep evidence only: component-local faces, reused FeatureGraph subset, contacts, mates, and measurements. Its old initial-extraction image refs are intentionally removed.
- Image evidence for this stage is restricted to available_target_images where image_kind=functional_group_all_face_highlight, generated under functional_group_evidence/<group_id>/all_face_highlights after functional-group split.
- Do not cite initial extraction images, component highlight images, context images, section_model_view images, or part_check reused image refs in image_evidence_names.
- For every feature-level local_semantic_units item, first look for matching all-face highlight images in available_target_images by highlight_face_refs. Cite only those exact evidence_name values and use their highlighted faces when deciding machining/non-machining boundaries, tool access, bosses, holes, planar pads, cylindrical seats, and local manufacturing risks.
- Use component_function_profile_input as the mandatory role context generated before local subgraph generation or supplied by the user for a single part. Do not reinvent the whole-component function from scratch in this prompt.
- Use diagnostic_context.part_function_profile and diagnostic_context.functional_block only as low-resolution fallback context. Determine each face/hole/feature role from local evidence and direct relations.
- Use diagnostic_context.user_product_description as user-supplied product-level function context when local B-rep/images/direct relations are ambiguous.
- Treat diagnostic_context.user_purchased_component_hints as exact complete component/file names supplied by the user for purchased/context-only components. Do not create nonstandard part-design diagnostic units for components already marked assembly_context_only.
- Use diagnostic_context.matched_inspection_cards as targeted dynamic inspection tasks: actively check only their key required_evidence, negative_checks, and unknown_if_missing against original evidence, but do not force a violation.
- diagnostic_context.matched_inspection_cards is for part/feature checks from the part-design knowledge library. Functional-group dynamic cards are stored on the matching functional_group_context group objects.
- For assembly component input, output top-level component_function_profile by copying/refining diagnostic_context.precomputed_component_function_profile for this exact component instance. Do not use this batch alone to redefine the component's overall role.
- Generate local units only from this feature batch's target_features, target_faces, available_target_images restricted to functional_group_all_face_highlight, direct relations as part context, scoped neighbor details when included, and matched part dynamic inspection cards.
- feature_batch.batch_feature_ids is a mandatory coverage list, not a suggestion. Generate one local_semantic_units item and one linked local_subgraphs item for every target_features item in this prompt.
- Functional-group coverage is not part of this prompt. Do not output anchor_type=functional_group or anchor_type=interface.
- Do not free-select "important" features from the batch. If target_features has 6 items, output at least 6 feature-anchored local_semantic_units unless the JSON evidence is internally contradictory; missing a batch feature is a contract failure.
- Do not collapse distinct B-rep feature anchors just because they share the same broad type. A FeatureGraph hole_group, cylindrical_surface_group, or planar_surface_candidate anchor normally needs its own local semantic unit when it supports a different check.
- Do not collapse different feature/interface anchors into one topic-level local_subgraph. A local_subgraph is anchored evidence for one concrete anchor, not a broad theme such as "all holes" or "all cylindrical surfaces".
- All available_target_images items in this stage should be functional_group_all_face_highlight images. Match them to target features by overlap between target_features[].FaceRefs and image.highlight_face_refs; do not require highlight_feature_id to equal the feature id.
- Face highlight image fields highlight_face_refs and highlight_view_direction_mm are binding evidence links. Do not cite a face highlight image when none of its highlight_face_refs belongs to the current anchor.
- For each local_semantic_units item, set inspection_card_refs only to rule_ids of inspection cards actively checked against that unit. Leave it empty when a card was only background context.
- The evidence JSON below is assembled only from this component evidence directory: `{{plan.EvidenceDirectory}}`.
- The first-round input is written to `{{plan.EvidencePath}}` for audit. Do not infer from global assembly evidence or unrelated component files.
- First-round neighbor information is summary-only. If neighbor B-rep or images are required, say so in neighbor_detail_request instead of guessing.
- Neighbor detail files under this component directory are not first-round evidence. Use them only when this prompt explicitly includes neighbor_details.
- Every round must include neighbor_detail_request, even when it is false.
- Do not use fault/judgement words or rule ids in retrieval_sentence. Inspection card ids may guide observation, but retrieval_sentence must remain a neutral fact sentence.
- Measurement wording must preserve units and meaning: RadiusMm/radius_mm is radius, not diameter. If a hole diameter is needed, use DiameterMm/diameter_mm or compute 2 * radius.
- retrieval_sentence must be one neutral Chinese fact sentence with no raw B-rep ids.
- anchor_type must be exactly one of: part or feature. Do not use face, mate, contact, relation, component, interface, or functional_group as anchor_type in this stage.
- retrieval_chain must be an object, never an array or string. It must contain exactly these semantic fields: geometric_fact_for_retrieval, mechanical_role, process_assembly_maintenance_semantics.
- Every local_semantic_units item must include: local_id, linked_subgraph_id, component_id, instance_name, anchor_type, anchor_id, pattern_type, primary_view, secondary_views, retrieval_sentence, retrieval_chain, inspection_card_refs, retrieval_semantic_roles, inferred_functions, inferred_attributes, retrieval_fact_tags, retrieval_relations, normalized_measurements, image_evidence_names, fact_bundle, dedup_key.
- Every fact_bundle must include component_id, instance_name, review_scope, semantic_location, evidence_refs, image_evidence_names, neighbor_evidence_refs, roles, measurements, uncertainties.
- When an inspection card cannot be fully checked, add the missing evidence to missing_facts and explain whether the missing evidence is geometric, assembly, process/PMI, or functional context.
- primary_view must be one of: assembly_contact_fit, disassembly_maintenance_access, drawing_annotation_requirement, feature_pattern, load_strength_stiffness, locating_limit_datum, motion_interference_dof, operation_safety_environment, part_local_shape, surface_role_boundary, system_function_condition, transmission_standard_parameters.

First-round output contract:
- Always return a valid fenced JSON object with schema mechanical_component_local_subgraph_v1. Do not return prose outside the fenced JSON block.
- component_function_profile must be present. For single-part input, set context_policy=disabled_for_part_input. For assembly input, include component_id, instance_name, function_introduction, assembly_function, related_components, interface_relations, responsibilities, evidence_refs, confidence.
- local_semantic_units must be non-empty.
- local_subgraphs must be non-empty. Each distinct feature/interface anchor_id in local_semantic_units must have its own local_subgraphs item, and linked_subgraph_id must point to that item.
- Batch coverage is mandatory: every target_features[].Id in this prompt must appear exactly once as a feature anchor_id in local_semantic_units, and must have a linked local_subgraphs item.
- Every local_semantic_units item must have a stable anchor_id that is a real target_features feature id or this component's part id.
- Every local_semantic_units item must cite real image_evidence_names from available_target_images when images are present.
- Every local_semantic_units item must cite concrete B-rep evidence_refs from target_features, target_faces, direct_contact_relations, direct_mate_relations, or included neighbor details.
- Do not use placeholders such as image_1, feature_or_interface_anchor_id, unknown_anchor, or generic evidence.
- If a fact cannot be verified, keep the local unit grounded in the known geometry and put the missing part in missing_facts; do not omit the whole JSON.
- If no dynamic inspection card applies to a feature, inspection_card_refs may be empty, but the unit must still be valid when the feature itself is diagnostically meaningful.

Return exactly one fenced JSON block with this schema:
```json
{
  "schema": "mechanical_component_local_subgraph_v1",
  "component_id": "{{plan.ComponentId}}",
  "instance_name": "{{JsonEscape(plan.InstanceName)}}",
  "component_function_profile": {
    "component_id": "{{plan.ComponentId}}",
    "instance_name": "{{JsonEscape(plan.InstanceName)}}",
    "function_introduction": "用一到三句话说明该零件在装配体中的功能；若为单零件输入则写明 disabled_for_part_input",
    "assembly_function": "该零件承担的装配功能、定位/支撑/连接/传动/密封/导向等角色",
    "related_components": ["直接相关的相邻零件或外购件名称"],
    "interface_relations": ["支撑该功能判断的接触/配合/同轴/贴合关系"],
    "responsibilities": ["仅保留当前证据支持的承载、定位、支撑、连接、运动限制等职责"],
    "evidence_refs": ["引用真实的 B-rep、图片、接触或配合证据 id"],
    "confidence": "low|medium|high"
  },
  "nodes": [],
  "edges": [],
  "measurements": [],
  "local_subgraphs": [
    {
      "id": "sg_component_anchor_id",
      "pattern_type": "neutral_pattern_type",
      "primary_view": "part_local_shape",
      "retrieval_sentence": "Neutral Chinese fact sentence for this one concrete anchor.",
      "roles": [],
      "evidence": {},
      "confidence": 0.0,
      "reason": "one local subgraph for one concrete feature or interface anchor"
    }
  ],
  "local_semantic_units": [
    {
      "local_id": "lsu_001",
      "linked_subgraph_id": "sg_001",
      "component_id": "{{plan.ComponentId}}",
      "instance_name": "{{JsonEscape(plan.InstanceName)}}",
      "anchor_type": "feature",
      "anchor_id": "feature_or_interface_anchor_id",
      "pattern_type": "neutral_pattern_type",
      "primary_view": "part_local_shape",
      "secondary_views": [],
      "retrieval_sentence": "Neutral Chinese fact sentence without rule id, judgement words, or raw B-rep ids.",
      "retrieval_chain": {
        "geometric_fact_for_retrieval": "summarized geometric fact",
        "mechanical_role": "mechanical role",
        "process_assembly_maintenance_semantics": "process, assembly, or maintenance semantics"
      },
      "inspection_card_refs": [],
      "retrieval_semantic_roles": [],
      "inferred_functions": [],
      "inferred_attributes": [],
      "retrieval_fact_tags": [],
      "retrieval_relations": [],
      "normalized_measurements": {},
      "image_evidence_names": [],
      "fact_bundle": {
        "component_id": "{{plan.ComponentId}}",
        "instance_name": "{{JsonEscape(plan.InstanceName)}}",
        "review_scope": "part_design_required",
        "semantic_location": "",
        "evidence_refs": [],
        "image_evidence_names": [],
        "neighbor_evidence_refs": [],
        "roles": [],
        "measurements": {},
        "uncertainties": []
      },
      "dedup_key": "stable_component_anchor_key"
    }
  ],
  "neighbor_detail_request": { "needs_neighbor_detail": false, "component_ids": [], "reason": "" },
  "missing_facts": [],
  "evidence_index": []
}
```

Component evidence JSON:
```json
{{json}}
```
""";
    }

    static string BuildAggregatedMechanicalSemanticGraph(string structureGraphPath, IReadOnlyList<ComponentPromptPlan> plans, IReadOnlyList<ComponentAiResult> results, string functionalGroupLocalSubgraphsPath = "")
    {
        var nodes = plans.Select(plan => JsonSerializer.SerializeToElement(new
        {
            id = plan.ComponentId,
            type = "component",
            review_scope = "part_design_required",
            display_name = plan.DisplayName,
            instance_name = plan.InstanceName,
            source_refs = new[] { plan.ComponentId },
            evidence = "component work queue target"
        })).ToList();
        var edges = new List<JsonElement>();
        var measurements = new List<JsonElement>();
        var localSubgraphs = new List<JsonElement>();
        var localUnits = new List<JsonElement>();
        var uncertainRoles = new List<JsonElement>();
        var missingFacts = new List<object>();
        var evidenceIndex = new List<JsonElement>();
        var componentFunctionProfiles = new List<JsonElement>();
        var coveredParts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var uncovered = new List<object>();
        var candidateFunctionalGroupIds = FunctionalGroupIdsForPlans(plans);

        if (!string.IsNullOrWhiteSpace(functionalGroupLocalSubgraphsPath) && File.Exists(functionalGroupLocalSubgraphsPath))
        {
            try
            {
                using var groupDoc = JsonDocument.Parse(File.ReadAllText(functionalGroupLocalSubgraphsPath));
                var groupRoot = groupDoc.RootElement;
                AddArray(localSubgraphs, groupRoot, "local_subgraphs");
                AddArray(localUnits, groupRoot, "local_semantic_units");
                foreach (var fact in ReadArray(groupRoot, "missing_facts"))
                    missingFacts.Add(fact.Clone());
            }
            catch (Exception ex)
            {
                uncovered.Add(new { anchor_type = "functional_group", anchor_id = "functional_group_local_subgraphs", reason_not_covered = $"functional group JSON parse failed: {ex.Message}" });
            }
        }

        foreach (var result in results)
        {
            if (string.IsNullOrWhiteSpace(result.LocalSubgraphJson))
            {
                uncovered.Add(new { anchor_type = "part", anchor_id = result.ComponentId, reason_not_covered = FirstNonEmpty(result.Message, "component AI output missing") });
                continue;
            }

            try
            {
                using var doc = JsonDocument.Parse(result.LocalSubgraphJson);
                var root = doc.RootElement;
                AddArray(nodes, root, "nodes");
                AddArray(edges, root, "edges");
                AddArray(measurements, root, "measurements");
                AddArray(localSubgraphs, root, "local_subgraphs");
                AddArray(localUnits, root, "local_semantic_units");
                AddArray(uncertainRoles, root, "uncertain_roles");
                AddArray(evidenceIndex, root, "evidence_index");
                if (root.TryGetProperty("component_function_profile", out var profile) && profile.ValueKind == JsonValueKind.Object)
                    componentFunctionProfiles.Add(profile.Clone());
                AddArray(componentFunctionProfiles, root, "component_function_profiles");
                foreach (var fact in ReadArray(root, "missing_facts"))
                    missingFacts.Add(fact.Clone());
                coveredParts.Add(result.ComponentId);
            }
            catch (Exception ex)
            {
                uncovered.Add(new { anchor_type = "part", anchor_id = result.ComponentId, reason_not_covered = $"component JSON parse failed: {ex.Message}" });
            }
        }

        var counts = CountLocalUnitAnchorTypes(localUnits);
        foreach (var plan in plans.Where(p => !coveredParts.Contains(p.ComponentId)))
            uncovered.Add(new { anchor_type = "part", anchor_id = plan.ComponentId, reason_not_covered = "component local subgraph was not generated successfully" });

            var graph = new
        {
            schema = "mechanical_semantic_graph_v1",
            generation_mode = "per_component_local_subgraph_collection",
            diagnostic_flow = new
            {
                sequence = new[] { DiagnosticFlowTerms.StageFunctionalGroupCheck, DiagnosticFlowTerms.StageGroupInternalPartCheck },
                boundary_policy = "Functional-group units use assembly_knowledge. Part/feature units generated after group checking use part_knowledge.",
                functional_group_stage = new
                {
                    diagnostic_stage = DiagnosticFlowTerms.StageFunctionalGroupCheck,
                    knowledge_scope = DiagnosticFlowTerms.KnowledgeScopeAssembly,
                    allowed_anchor_types = new[] { "functional_group", "interface" }
                },
                group_internal_part_stage = new
                {
                    diagnostic_stage = DiagnosticFlowTerms.StageGroupInternalPartCheck,
                    knowledge_scope = DiagnosticFlowTerms.KnowledgeScopePart,
                    allowed_anchor_types = new[] { "part", "feature" }
                }
            },
            source_structure_graph_path = structureGraphPath,
            model_identity = new
            {
                name = Path.GetFileNameWithoutExtension(ReadModelPathFromStructure(structureGraphPath)),
                source_assembly_path = ReadModelPathFromStructure(structureGraphPath),
                likely_equipment_type = "",
                confidence = 0.0,
                evidence = new[] { "per-component local subgraph generation" }
            },
            nodes,
            edges,
            measurements,
            semantic_coverage_audit = new
            {
                purpose = "Coverage audit for per-component local subgraph generation.",
                candidate_anchor_types = new[] { "part", "feature", "interface", "functional_group" },
                covered_part_component_ids = coveredParts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(),
                candidate_functional_group_ids = candidateFunctionalGroupIds,
                covered_feature_anchor_ids = LocalUnitAnchorIds(localUnits, "feature"),
                covered_interface_relation_ids = LocalUnitAnchorIds(localUnits, "interface"),
                covered_functional_group_ids = LocalUnitAnchorIds(localUnits, "functional_group"),
                uncovered_candidate_anchors = uncovered,
                local_unit_count_by_anchor_type = new
                {
                    part = counts.GetValueOrDefault("part"),
                    feature = counts.GetValueOrDefault("feature"),
                    @interface = counts.GetValueOrDefault("interface"),
                    functional_group = counts.GetValueOrDefault("functional_group")
                }
            },
            local_semantic_units = localUnits,
            local_subgraphs = localSubgraphs,
            component_function_profiles = componentFunctionProfiles,
            uncertain_roles = uncertainRoles,
            missing_facts = missingFacts,
            evidence_index = evidenceIndex
        };

        return JsonSerializer.Serialize(graph, JsonOptions.Default)
            .Replace("\"interface\":", "\"interface\":", StringComparison.Ordinal);

        static void AddArray(List<JsonElement> target, JsonElement root, string property)
        {
            foreach (var item in ReadArray(root, property))
                target.Add(item.Clone());
        }
    }

    static List<string> FunctionalGroupIdsForPlans(IReadOnlyList<ComponentPromptPlan> plans)
    {
        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var plan in plans)
        {
            if (plan.FunctionalGroupContext["groups"] is not JsonArray groups)
                continue;
            foreach (var group in groups.OfType<JsonObject>())
            {
                var groupId = NodeString(group, "group_id");
                if (!string.IsNullOrWhiteSpace(groupId))
                    ids.Add(groupId);
            }
        }
        return ids.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToList();
    }

    static string BuildLocalSubgraphCollection(IReadOnlyList<ComponentPromptPlan> plans, IReadOnlyList<ComponentAiResult> results)
    {
        var items = results.Select(result =>
        {
            object? parsed = null;
            if (!string.IsNullOrWhiteSpace(result.LocalSubgraphJson))
            {
                try
                {
                    parsed = JsonSerializer.Deserialize<JsonElement>(result.LocalSubgraphJson);
                }
                catch
                {
                    parsed = null;
                }
            }

            return new
            {
                result.ComponentId,
                result.InstanceName,
                result.DisplayName,
                result.Status,
                result.Message,
                result.PromptPath,
                result.EnrichedPromptPath,
                local_subgraph = parsed
            };
        }).ToList();

        return JsonSerializer.Serialize(new
        {
            schema = "local_subgraph_collection_v1",
            generated_from = "per_component_ai",
            components = plans.Select(p => new { p.ComponentId, p.InstanceName, p.DisplayName, adjacent_component_ids = p.AdjacentComponentIds }),
            local_subgraphs = items
        }, JsonOptions.Default);
    }

    static string BuildPerComponentInterpretationMarkdown(IReadOnlyList<ComponentAiResult> results)
    {
        var sb = new StringBuilder();
        sb.AppendLine("# Per-component Local Subgraph Generation");
        sb.AppendLine();
        foreach (var result in results)
        {
            sb.AppendLine($"## {result.ComponentId} {result.DisplayName}");
            sb.AppendLine();
            sb.AppendLine($"- Status: {result.Status}");
            if (!string.IsNullOrWhiteSpace(result.Message))
                sb.AppendLine($"- Message: {result.Message}");
            if (!string.IsNullOrWhiteSpace(result.PromptPath))
                sb.AppendLine($"- Prompt: `{result.PromptPath}`");
            if (!string.IsNullOrWhiteSpace(result.EnrichedPromptPath))
                sb.AppendLine($"- Enriched prompt: `{result.EnrichedPromptPath}`");
            sb.AppendLine();
            if (!string.IsNullOrWhiteSpace(result.ResponseText))
            {
                sb.AppendLine(result.ResponseText);
                sb.AppendLine();
            }
        }

        return sb.ToString();
    }

    static (string JsonPath, string MarkdownPath) WriteComponentFunctionIntroductionArtifacts(
        string outputDir,
        IReadOnlyList<ComponentPromptPlan> plans,
        IReadOnlyList<ComponentAiResult> results)
    {
        var contextDir = Path.Combine(outputDir, "diagnostic_context");
        Directory.CreateDirectory(contextDir);

        var jsonPath = Path.Combine(contextDir, "component_function_introductions.json");
        var markdownPath = Path.Combine(contextDir, "component_function_introductions.md");
        var components = new JsonArray();
        var markdown = new StringBuilder();
        markdown.AppendLine("# 各零件功能介绍");
        markdown.AppendLine();
        markdown.AppendLine("该文件用于单独检查 LLM 对装配体中各零件功能的理解质量。");
        markdown.AppendLine();

        foreach (var plan in plans.GroupBy(plan => plan.ComponentId, StringComparer.OrdinalIgnoreCase).Select(group => group.First()))
        {
            var componentResults = results
                .Where(result => result.ComponentId.Equals(plan.ComponentId, StringComparison.OrdinalIgnoreCase))
                .ToList();
            var aiProfiles = componentResults
                .SelectMany(ExtractComponentFunctionProfiles)
                .ToList();
            var selectedProfile = aiProfiles
                .OrderByDescending(ProfileTextScore)
                .FirstOrDefault();
            var fallbackProfile = JsonSerializer.SerializeToNode(plan.PartFunctionProfile, JsonOptions.Default) as JsonObject
                                  ?? new JsonObject();
            var source = selectedProfile == null ? "diagnostic_context_fallback" : "ai_component_function_profile";
            selectedProfile ??= fallbackProfile;

            var intro = FirstNonEmpty(
                NodeString(selectedProfile, "function_introduction"),
                NodeString(selectedProfile, "function_summary"),
                NodeString(selectedProfile, "assembly_function"),
                NodeString(selectedProfile, "part_role"),
                NodeString(selectedProfile, "inferred_role"),
                NodeString(selectedProfile, "role"),
                JsonNodeText(selectedProfile["functions"]),
                JsonNodeText(selectedProfile["responsibilities"]));
            var interfaces = FirstNonEmpty(
                JsonNodeText(selectedProfile["related_components"]),
                JsonNodeText(selectedProfile["supporting_components"]),
                JsonNodeText(selectedProfile["supported_components"]),
                JsonNodeText(selectedProfile["interface_relations"]),
                JsonNodeText(selectedProfile["evidence_refs"]));
            var confidence = FirstNonEmpty(
                NodeString(selectedProfile, "confidence"),
                NodeString(selectedProfile, "confidence_level"),
                NodeString(selectedProfile, "certainty"),
                "unknown");

            var aiProfileArray = new JsonArray();
            foreach (var profile in aiProfiles)
                aiProfileArray.Add(CloneJsonNode(profile));

            components.Add(new JsonObject
            {
                ["component_id"] = plan.ComponentId,
                ["instance_name"] = plan.InstanceName,
                ["display_name"] = plan.DisplayName,
                ["review_scope"] = ReadString(plan.Package, "ReviewScope"),
                ["profile_source"] = source,
                ["function_introduction"] = intro,
                ["interface_summary"] = interfaces,
                ["confidence"] = confidence,
                ["selected_profile"] = CloneJsonNode(selectedProfile),
                ["all_ai_profiles"] = aiProfileArray,
                ["fallback_profile"] = CloneJsonNode(fallbackProfile)
            });

            markdown.AppendLine($"## {plan.ComponentId} {plan.DisplayName}");
            markdown.AppendLine();
            markdown.AppendLine($"- 实例名：{plan.InstanceName}");
            markdown.AppendLine($"- 分析范围：{ReadString(plan.Package, "ReviewScope")}");
            markdown.AppendLine($"- 来源：{source}");
            markdown.AppendLine($"- 置信度：{confidence}");
            markdown.AppendLine($"- 功能介绍：{FirstNonEmpty(intro, "未给出明确功能介绍。")}");
            if (!string.IsNullOrWhiteSpace(interfaces))
                markdown.AppendLine($"- 相关接口/相邻件：{interfaces}");
            markdown.AppendLine();
        }

        File.WriteAllText(jsonPath, JsonSerializer.Serialize(new
        {
            schema = "component_function_introductions_v1",
            generation_mode = "extracted_from_per_component_llm_outputs_with_diagnostic_context_fallback",
            usage_policy = "Human-readable audit artifact for evaluating component function understanding. It is not a final diagnostic judgement.",
            output_dir = outputDir,
            component_count = components.Count,
            components
        }, JsonOptions.Default), new UTF8Encoding(false));
        File.WriteAllText(markdownPath, markdown.ToString(), new UTF8Encoding(false));
        return (jsonPath, markdownPath);
    }

    static IEnumerable<JsonObject> ExtractComponentFunctionProfiles(ComponentAiResult result)
    {
        if (string.IsNullOrWhiteSpace(result.LocalSubgraphJson))
            yield break;

        JsonObject? root;
        try
        {
            root = JsonNode.Parse(result.LocalSubgraphJson) as JsonObject;
        }
        catch
        {
            yield break;
        }

        if (root == null)
            yield break;

        if (root["component_function_profile"] is JsonObject single)
            yield return CloneJsonObject(single, result);

        if (root["component_function_profiles"] is JsonArray array)
        {
            foreach (var profile in array.OfType<JsonObject>())
                yield return CloneJsonObject(profile, result);
        }
    }

    static JsonObject CloneJsonObject(JsonObject source, ComponentAiResult result)
    {
        var clone = CloneJsonNode(source) as JsonObject ?? new JsonObject();
        clone["source_batch_id"] = result.BatchId;
        clone["source_batch_index"] = result.BatchIndex;
        clone["source_result_path"] = result.ResultPath;
        return clone;
    }

    static JsonNode? CloneJsonNode(JsonNode? node)
    {
        return node == null ? null : JsonNode.Parse(node.ToJsonString(JsonOptions.Default));
    }

    static int ProfileTextScore(JsonObject profile)
    {
        var text = string.Join(" ", new[]
        {
            NodeString(profile, "function_introduction"),
            NodeString(profile, "function_summary"),
            NodeString(profile, "assembly_function"),
            NodeString(profile, "part_role"),
            NodeString(profile, "inferred_role"),
            JsonNodeText(profile["functions"]),
            JsonNodeText(profile["responsibilities"]),
            JsonNodeText(profile["interface_relations"]),
            JsonNodeText(profile["evidence_refs"])
        });
        return text.Length;
    }

    static void WriteLocalSubgraphProgress(
        string path,
        int totalComponents,
        int componentIndex,
        ComponentPromptPlan plan,
        int iteration,
        string status,
        string message)
    {
        var payload = new
        {
            schema = "local_subgraph_progress_v1",
            total_components = totalComponents,
            processed_components = Math.Clamp(componentIndex, 0, totalComponents),
            current_index = componentIndex + 1,
            current_component = new
            {
                component_id = plan.ComponentId,
                instance_name = plan.InstanceName,
                display_name = plan.DisplayName,
                evidence_path = plan.EvidencePath,
                feature_count = plan.Features.Count,
                face_count = plan.Faces.Count,
                contact_count = plan.Contacts.Count,
                mate_count = plan.Mates.Count,
                image_count = plan.AvailableImages.Count,
                adjacent_component_ids = plan.AdjacentComponentIds
            },
            iteration,
            status,
            message,
            updated_at = DateTimeOffset.Now
        };

        File.WriteAllText(path, JsonSerializer.Serialize(payload, JsonOptions.Default), new UTF8Encoding(false));
    }

    static string? NormalizeComponentLocalSubgraphJson(string? localJson, ComponentPromptPlan plan)
    {
        if (string.IsNullOrWhiteSpace(localJson))
            return localJson;

        try
        {
            var root = JsonNode.Parse(localJson) as JsonObject;
            if (root == null)
                return localJson;

            root["schema"] = "mechanical_component_local_subgraph_v1";
            root["diagnostic_stage"] = plan.DiagnosticStage;
            root["knowledge_scope"] = plan.KnowledgeScope;
            root["allowed_anchor_types"] = new JsonArray("part", "feature");
            root["forbidden_anchor_types"] = new JsonArray("interface", "functional_group");
            root["component_id"] = plan.ComponentId;
            if (string.IsNullOrWhiteSpace(NodeString(root, "instance_name")))
                root["instance_name"] = plan.InstanceName;
            if (root["component_function_profile"] is not JsonObject)
            {
                var fallback = JsonSerializer.SerializeToNode(plan.PartFunctionProfile, JsonOptions.Default) as JsonObject
                               ?? new JsonObject();
                fallback["component_id"] = plan.ComponentId;
                fallback["instance_name"] = plan.InstanceName;
                fallback["profile_source"] = plan.IsPartDocument ? "disabled_for_part_input" : "diagnostic_context_fallback";
                if (plan.IsPartDocument)
                    fallback["context_policy"] = "disabled_for_part_input";
                root["component_function_profile"] = fallback;
            }

            var units = root["local_semantic_units"] as JsonArray;
            if (units != null)
            {
                foreach (var node in units.OfType<JsonObject>())
                    NormalizeLocalSemanticUnit(node, plan);
            }

            var subgraphs = root["local_subgraphs"] as JsonArray;
            if (units != null && units.Count > 0)
            {
                subgraphs = BuildSubgraphsForLocalUnits(units);
                root["local_subgraphs"] = subgraphs;
            }

            if (root["neighbor_detail_request"] is not JsonObject)
            {
                root["neighbor_detail_request"] = new JsonObject
                {
                    ["needs_neighbor_detail"] = false,
                    ["component_ids"] = new JsonArray(),
                    ["reason"] = ""
                };
            }

            root["missing_facts"] ??= new JsonArray();
            root["evidence_index"] ??= new JsonArray();
            return root.ToJsonString(JsonOptions.Default);
        }
        catch
        {
            return localJson;
        }
    }

    static void NormalizeLocalSemanticUnit(JsonObject unit, ComponentPromptPlan plan)
    {
        unit["diagnostic_stage"] = plan.DiagnosticStage;
        unit["knowledge_scope"] = plan.KnowledgeScope;
        if (string.IsNullOrWhiteSpace(NodeString(unit, "component_id")))
            unit["component_id"] = plan.ComponentId;
        if (string.IsNullOrWhiteSpace(NodeString(unit, "instance_name")))
            unit["instance_name"] = plan.InstanceName;

        var anchorType = NormalizeLocalAnchorType(
            NodeString(unit, "anchor_type"),
            NodeString(unit, "primary_view"),
            NodeString(unit, "pattern_type"),
            JsonNodeText(unit["retrieval_relations"]));
        unit["anchor_type"] = anchorType;

        if (string.IsNullOrWhiteSpace(NodeString(unit, "linked_subgraph_id")))
            unit["linked_subgraph_id"] = $"sg_{FirstNonEmpty(NodeString(unit, "local_id"), Guid.NewGuid().ToString("N")[..8])}";
        var anchorId = NodeString(unit, "anchor_id");
        if (!string.IsNullOrWhiteSpace(anchorId))
            unit["linked_subgraph_id"] = StableLocalSubgraphId(NodeString(unit, "component_id"), anchorType, anchorId);
        if (string.IsNullOrWhiteSpace(NodeString(unit, "dedup_key")))
            unit["dedup_key"] = $"{plan.ComponentId}_{FirstNonEmpty(NodeString(unit, "anchor_id"), NodeString(unit, "local_id"))}";

        unit["retrieval_chain"] = NormalizeRetrievalChain(unit);
        unit["fact_bundle"] = NormalizeFactBundle(unit, plan);
        unit["secondary_views"] ??= new JsonArray();
        unit["retrieval_semantic_roles"] ??= new JsonArray();
        unit["inferred_functions"] ??= new JsonArray();
        unit["inferred_attributes"] ??= new JsonArray();
        unit["retrieval_fact_tags"] ??= new JsonArray();
        unit["retrieval_relations"] ??= new JsonArray();
        unit["normalized_measurements"] ??= new JsonObject();
        unit["image_evidence_names"] ??= new JsonArray();
    }

    static string NormalizeLocalAnchorType(string anchorType, string primaryView, string patternType, string relationsText)
    {
        var text = $"{anchorType} {primaryView} {patternType} {relationsText}".ToLowerInvariant();
        if (text.Contains("mate") || text.Contains("contact") || text.Contains("fit") || text.Contains("interface") || text.Contains("assembly_contact_fit"))
            return "interface";
        if (anchorType.Equals("component", StringComparison.OrdinalIgnoreCase) || anchorType.Equals("part", StringComparison.OrdinalIgnoreCase))
            return "part";
        if (anchorType.Equals("group", StringComparison.OrdinalIgnoreCase) || anchorType.Equals("functional_group", StringComparison.OrdinalIgnoreCase))
            return "functional_group";
        return "feature";
    }

    static JsonObject NormalizeRetrievalChain(JsonObject unit)
    {
        var existing = unit["retrieval_chain"];
        if (existing is JsonObject obj)
        {
            return new JsonObject
            {
                ["geometric_fact_for_retrieval"] = FirstNonEmpty(NodeString(obj, "geometric_fact_for_retrieval"), NodeString(unit, "retrieval_sentence")),
                ["mechanical_role"] = FirstNonEmpty(NodeString(obj, "mechanical_role"), JoinNodeValues(unit["retrieval_semantic_roles"]), JoinNodeValues(unit["inferred_functions"])),
                ["process_assembly_maintenance_semantics"] = FirstNonEmpty(NodeString(obj, "process_assembly_maintenance_semantics"), JoinNodeValues(unit["retrieval_fact_tags"]), JoinNodeValues(unit["retrieval_relations"]))
            };
        }

        return new JsonObject
        {
            ["geometric_fact_for_retrieval"] = FirstNonEmpty(JsonNodeText(existing), NodeString(unit, "retrieval_sentence")),
            ["mechanical_role"] = FirstNonEmpty(JoinNodeValues(unit["retrieval_semantic_roles"]), JoinNodeValues(unit["inferred_functions"]), NodeString(unit, "pattern_type")),
            ["process_assembly_maintenance_semantics"] = FirstNonEmpty(JoinNodeValues(unit["retrieval_fact_tags"]), JoinNodeValues(unit["retrieval_relations"]), JsonNodeText(unit["inferred_attributes"]))
        };
    }

    static JsonObject NormalizeFactBundle(JsonObject unit, ComponentPromptPlan plan)
    {
        var fact = unit["fact_bundle"] as JsonObject ?? new JsonObject();
        fact["component_id"] = FirstNonEmpty(NodeString(fact, "component_id"), plan.ComponentId);
        fact["instance_name"] = FirstNonEmpty(NodeString(fact, "instance_name"), plan.InstanceName);
        fact["review_scope"] = FirstNonEmpty(NodeString(fact, "review_scope"), "part_design_required");
        fact["semantic_location"] = FirstNonEmpty(NodeString(fact, "semantic_location"), NodeString(unit, "anchor_id"));
        fact["evidence_refs"] ??= new JsonArray();
        fact["image_evidence_names"] ??= CloneNode(unit["image_evidence_names"]) ?? new JsonArray();
        fact["neighbor_evidence_refs"] ??= new JsonArray();
        fact["roles"] ??= CloneNode(unit["retrieval_semantic_roles"]) ?? new JsonArray();
        fact["measurements"] ??= CloneNode(unit["normalized_measurements"]) ?? new JsonObject();
        fact["uncertainties"] ??= new JsonArray();
        return fact;
    }

    static JsonObject BuildSubgraphFromUnit(JsonObject unit)
    {
        return new JsonObject
        {
            ["id"] = FirstNonEmpty(NodeString(unit, "linked_subgraph_id"), $"sg_{NodeString(unit, "local_id")}"),
            ["pattern_type"] = NodeString(unit, "pattern_type"),
            ["primary_view"] = NodeString(unit, "primary_view"),
            ["retrieval_sentence"] = NodeString(unit, "retrieval_sentence"),
            ["roles"] = CloneNode(unit["retrieval_semantic_roles"]) ?? new JsonArray(),
            ["evidence"] = CloneNode(unit["fact_bundle"]) ?? new JsonObject(),
            ["confidence"] = 0.5,
            ["reason"] = "generated from normalized local_semantic_unit"
        };
    }

    static JsonArray BuildSubgraphsForLocalUnits(JsonArray units)
    {
        var subgraphs = new JsonArray();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var unit in units.OfType<JsonObject>())
        {
            var subgraphId = NodeString(unit, "linked_subgraph_id");
            if (string.IsNullOrWhiteSpace(subgraphId) || !seen.Add(subgraphId))
                continue;
            subgraphs.Add(BuildSubgraphFromUnit(unit));
        }
        return subgraphs;
    }

    static string StableLocalSubgraphId(string componentId, string anchorType, string anchorId)
    {
        var raw = $"{FirstNonEmpty(componentId, "component")}_{FirstNonEmpty(anchorType, "anchor")}_{anchorId}".ToLowerInvariant();
        var stable = Regex.Replace(raw, @"[^a-z0-9_\-\u4e00-\u9fff]+", "_").Trim('_');
        return $"sg_{FirstNonEmpty(stable, Guid.NewGuid().ToString("N")[..8])}";
    }

    static JsonNode? CloneNode(JsonNode? node) => node == null ? null : JsonNode.Parse(node.ToJsonString());

    static string NodeString(JsonObject obj, string property)
    {
        return obj.TryGetPropertyValue(property, out var value) && value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var text)
            ? text
            : "";
    }

    static string JoinNodeValues(JsonNode? node)
    {
        if (node is JsonArray array)
            return string.Join(", ", array.Select(JsonNodeText).Where(value => !string.IsNullOrWhiteSpace(value)));
        return JsonNodeText(node);
    }

    static string JsonNodeText(JsonNode? node)
    {
        if (node == null)
            return "";
        if (node is JsonValue value && value.TryGetValue<string>(out var text))
            return text;
        if (node is JsonArray array)
            return string.Join(", ", array.Select(JsonNodeText).Where(value => !string.IsNullOrWhiteSpace(value)));
        return node.ToJsonString(JsonOptions.Default);
    }

    static NeighborDetailRequest ExtractNeighborDetailRequest(string? localJson, ComponentPromptPlan plan)
    {
        var request = new NeighborDetailRequest();
        if (string.IsNullOrWhiteSpace(localJson))
            return request;

        try
        {
            using var doc = JsonDocument.Parse(localJson);
            var root = doc.RootElement;
            if (!root.TryGetProperty("neighbor_detail_request", out var node) || node.ValueKind != JsonValueKind.Object)
                return request;

            request.NeedsNeighborDetail = node.TryGetProperty("needs_neighbor_detail", out var needs) &&
                                          needs.ValueKind is JsonValueKind.True or JsonValueKind.False &&
                                          needs.GetBoolean();
            request.ComponentIds = ReadStringArrayValue(node, "component_ids")
                .Where(id => plan.AdjacentComponentIds.Contains(id, StringComparer.OrdinalIgnoreCase))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToList();
            request.Reason = ReadString(node, "reason");
        }
        catch
        {
            return request;
        }

        return request;
    }

    static List<string> ValidateComponentLocalSubgraphContract(string? localJson, ComponentPromptPlan plan)
    {
        var issues = new List<string>();
        if (string.IsNullOrWhiteSpace(localJson))
        {
            issues.Add("missing mechanical_component_local_subgraph_v1 JSON block");
            return issues;
        }

        try
        {
            using var doc = JsonDocument.Parse(localJson);
            var root = doc.RootElement;
            var schema = ReadString(root, "schema");
            if (!schema.Equals("mechanical_component_local_subgraph_v1", StringComparison.OrdinalIgnoreCase) &&
                !schema.Equals("mechanical_semantic_graph_v1", StringComparison.OrdinalIgnoreCase))
            {
                issues.Add($"unexpected schema: {schema}");
            }

            if (!ReadString(root, "component_id").Equals(plan.ComponentId, StringComparison.OrdinalIgnoreCase) &&
                schema.Equals("mechanical_component_local_subgraph_v1", StringComparison.OrdinalIgnoreCase))
            {
                issues.Add($"component_id must be {plan.ComponentId}");
            }

            var units = ReadArray(root, "local_semantic_units");
            if (units.Count == 0)
                issues.Add("local_semantic_units is missing or empty");

            var subgraphs = ReadArray(root, "local_subgraphs");
            if (subgraphs.Count == 0)
                issues.Add("local_subgraphs is missing or empty");

            var requiredFeatureIds = plan.Features
                .Select(feature => ReadString(feature, "Id"))
                .Where(id => !string.IsNullOrWhiteSpace(id))
                .ToHashSet(StringComparer.OrdinalIgnoreCase);
            var dedupKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var coveredSeedAnchors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var coveredFeatureAnchors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var index = 0;
            foreach (var unit in units)
            {
                index++;
                var id = FirstNonEmpty(ReadString(unit, "local_id"), ReadString(unit, "id"), $"#{index}");
                var anchorType = ReadString(unit, "anchor_type");
                var anchorId = FirstNonEmpty(ReadString(unit, "anchor_id"), ReadFactBundleAnchorId(unit));
                var retrievalSentence = ReadString(unit, "retrieval_sentence");
                var dedupKey = ReadString(unit, "dedup_key");
                if (!string.IsNullOrWhiteSpace(anchorId))
                    coveredSeedAnchors.Add(anchorId);
                if (anchorType.Equals("feature", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(anchorId))
                    coveredFeatureAnchors.Add(anchorId);
                if (string.IsNullOrWhiteSpace(ReadString(unit, "primary_view")))
                    issues.Add($"{id}: primary_view is empty");
                if (string.IsNullOrWhiteSpace(anchorType))
                    issues.Add($"{id}: anchor_type is empty");
                else if (!IsAllowedAnchorType(anchorType))
                    issues.Add($"{id}: invalid anchor_type {anchorType}");
                if (string.IsNullOrWhiteSpace(anchorId))
                    issues.Add($"{id}: anchor_id or fact_bundle.anchor.id is required");
                if (string.IsNullOrWhiteSpace(retrievalSentence))
                    issues.Add($"{id}: retrieval_sentence is empty");
                if (HasJudgementWords(retrievalSentence))
                    issues.Add($"{id}: retrieval_sentence contains judgement words");
                if (HasTechnicalBrepReference(retrievalSentence))
                    issues.Add($"{id}: retrieval_sentence contains raw B-rep ids");
                if (string.IsNullOrWhiteSpace(dedupKey))
                    issues.Add($"{id}: dedup_key is empty");
                else if (!dedupKeys.Add(dedupKey))
                    issues.Add($"{id}: duplicate dedup_key {dedupKey}");
                if (!unit.TryGetProperty("retrieval_chain", out var chain) || chain.ValueKind != JsonValueKind.Object)
                    issues.Add($"{id}: retrieval_chain is missing");
                else
                {
                    if (string.IsNullOrWhiteSpace(ReadString(chain, "geometric_fact_for_retrieval")))
                        issues.Add($"{id}: retrieval_chain.geometric_fact_for_retrieval is empty");
                    if (string.IsNullOrWhiteSpace(ReadString(chain, "mechanical_role")))
                        issues.Add($"{id}: retrieval_chain.mechanical_role is empty");
                    if (string.IsNullOrWhiteSpace(ReadString(chain, "process_assembly_maintenance_semantics")))
                        issues.Add($"{id}: retrieval_chain.process_assembly_maintenance_semantics is empty");
                }
                if (!unit.TryGetProperty("fact_bundle", out var factBundle) || factBundle.ValueKind != JsonValueKind.Object)
                    issues.Add($"{id}: fact_bundle is missing");
                if (CountArray(unit, "image_evidence_names") == 0 && CountArray(factBundle, "image_evidence_names") == 0)
                    issues.Add($"{id}: image_evidence_names is empty");
            }

            foreach (var missingFeatureId in requiredFeatureIds.Except(coveredFeatureAnchors, StringComparer.OrdinalIgnoreCase).OrderBy(id => id, StringComparer.OrdinalIgnoreCase))
                issues.Add($"missing batch target feature anchor: {missingFeatureId}");

            foreach (var extraFeatureId in coveredFeatureAnchors.Except(requiredFeatureIds, StringComparer.OrdinalIgnoreCase).OrderBy(id => id, StringComparer.OrdinalIgnoreCase))
                issues.Add($"feature anchor is not in current batch target_features: {extraFeatureId}");

            if (!root.TryGetProperty("neighbor_detail_request", out var neighborRequest) || neighborRequest.ValueKind != JsonValueKind.Object)
                issues.Add("neighbor_detail_request is missing");
        }
        catch (Exception ex)
        {
            issues.Add($"component local subgraph JSON cannot be parsed: {ex.Message}");
        }

        return issues;
    }

    static List<string> CollectComponentImagePaths(string outputDir, ComponentPromptPlan plan, IReadOnlyList<string> requestedNeighborIds, int maxImages)
    {
        if (maxImages <= 0)
            return [];

        var paths = plan.AvailableImages
            .Select(image => ReadString(image, "output_path"))
            .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();

        if (requestedNeighborIds.Count > 0 && Directory.Exists(outputDir))
        {
            var requested = requestedNeighborIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
            var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
            paths.AddRange(Directory.EnumerateFiles(outputDir, "*.*", SearchOption.AllDirectories)
                .Where(path => allowed.Contains(Path.GetExtension(path)))
                .Where(path => requested.Any(id => path.Contains(id, StringComparison.OrdinalIgnoreCase)))
                .Where(File.Exists));
        }

        return paths
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(ImagePriority)
            .ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
            .Take(maxImages)
            .ToList();
    }

    static List<JsonElement> ReadArray(JsonElement element, string property)
    {
        if (element.ValueKind != JsonValueKind.Object ||
            !element.TryGetProperty(property, out var value) ||
            value.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        return value.EnumerateArray().Select(item => item.Clone()).ToList();
    }

    static bool TryGetPath(JsonElement element, out JsonElement value, params string[] path)
    {
        value = element;
        foreach (var property in path)
        {
            if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(property, out value))
            {
                value = default;
                return false;
            }
        }

        return true;
    }

    static List<string> ReadStringArrayValue(JsonElement element, string property)
    {
        if (element.ValueKind != JsonValueKind.Object ||
            !element.TryGetProperty(property, out var value) ||
            value.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        return value.EnumerateArray()
            .Where(item => item.ValueKind == JsonValueKind.String)
            .Select(item => item.GetString() ?? "")
            .Where(item => item.Length > 0)
            .ToList();
    }

    static bool ComponentTouchesContact(JsonElement package, JsonElement contact)
    {
        return SameComponentByNameAndPath(package, ReadString(contact, "ComponentA"), ReadString(contact, "ComponentPathA")) ||
               SameComponentByNameAndPath(package, ReadString(contact, "ComponentB"), ReadString(contact, "ComponentPathB"));
    }

    static bool ComponentTouchesMate(JsonElement package, JsonElement mate)
    {
        return SameComponentByNameAndPath(package, ReadString(mate, "ComponentA"), ReadString(mate, "ComponentPathA")) ||
               SameComponentByNameAndPath(package, ReadString(mate, "ComponentB"), ReadString(mate, "ComponentPathB"));
    }

    static bool SameComponentByNameAndPath(JsonElement package, string instanceName, string path)
    {
        var packageInstance = ReadString(package, "InstanceName");
        var packagePath = ReadString(package, "Path");
        if (!string.IsNullOrWhiteSpace(packageInstance) &&
            packageInstance.Equals(instanceName, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        return !string.IsNullOrWhiteSpace(packagePath) &&
               !string.IsNullOrWhiteSpace(path) &&
               Path.GetFullPath(packagePath).Equals(Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase);
    }

    static string FaceEvidenceRef(JsonElement face)
    {
        var componentName = ReadString(face, "ComponentName");
        var faceIndex = ReadInt(face, "FaceIndex");
        return string.IsNullOrWhiteSpace(componentName) ? "" : $"{componentName}:face#{faceIndex}";
    }

    static List<string> ResolveAdjacentComponentIds(
        JsonElement package,
        IReadOnlyList<JsonElement> contacts,
        IReadOnlyList<JsonElement> mates,
        IReadOnlyDictionary<string, JsonElement> componentsByInstance)
    {
        var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var contact in contacts)
        {
            AddNeighborFromSides(contact, "ComponentA", "ComponentPathA", "ComponentB", package, componentsByInstance, result);
            AddNeighborFromSides(contact, "ComponentB", "ComponentPathB", "ComponentA", package, componentsByInstance, result);
        }
        foreach (var mate in mates)
        {
            AddNeighborFromSides(mate, "ComponentA", "ComponentPathA", "ComponentB", package, componentsByInstance, result);
            AddNeighborFromSides(mate, "ComponentB", "ComponentPathB", "ComponentA", package, componentsByInstance, result);
        }

        return result.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
    }

    static void AddNeighborFromSides(
        JsonElement relation,
        string targetNameProperty,
        string targetPathProperty,
        string neighborNameProperty,
        JsonElement package,
        IReadOnlyDictionary<string, JsonElement> componentsByInstance,
        HashSet<string> result)
    {
        if (!SameComponentByNameAndPath(package, ReadString(relation, targetNameProperty), ReadString(relation, targetPathProperty)))
            return;

        var neighborName = ReadString(relation, neighborNameProperty);
        if (componentsByInstance.TryGetValue(neighborName, out var neighbor))
        {
            var id = ReadString(neighbor, "Id");
            if (!string.IsNullOrWhiteSpace(id) && !id.Equals(ReadString(package, "ComponentId"), StringComparison.OrdinalIgnoreCase))
                result.Add(id);
        }
    }

    static JsonElement BuildNeighborSummary(JsonElement component)
    {
        return JsonSerializer.SerializeToElement(new
        {
            component_id = ReadString(component, "Id"),
            instance_name = ReadString(component, "InstanceName"),
            display_name = ReadString(component, "DisplayName"),
            category = ReadString(component, "Category"),
            review_scope = ReadString(component, "ReviewScope"),
            face_count = ReadInt(component, "FaceCount"),
            cylinder_face_count = ReadInt(component, "CylinderFaceCount"),
            bbox_mm = component.TryGetProperty("BBoxMm", out var bbox) && bbox.ValueKind == JsonValueKind.Array ? bbox.Clone() : default
        });
    }

    static bool RelationTouchesComponent(JsonElement relation, JsonElement package, JsonElement component)
    {
        return RelationSideMatchesComponent(relation, "A", package, component) ||
               RelationSideMatchesComponent(relation, "B", package, component);
    }

    static bool RelationSideMatchesComponent(JsonElement relation, string side, JsonElement package, JsonElement component)
    {
        var name = ReadString(relation, $"Component{side}");
        var path = ReadString(relation, $"ComponentPath{side}");

        if (package.ValueKind != JsonValueKind.Undefined && SameComponentByNameAndPath(package, name, path))
            return true;

        var instanceName = ReadString(component, "InstanceName");
        var componentPath = ReadString(component, "Path");
        return !string.IsNullOrWhiteSpace(instanceName) && instanceName.Equals(name, StringComparison.OrdinalIgnoreCase) ||
               !string.IsNullOrWhiteSpace(componentPath) &&
               !string.IsNullOrWhiteSpace(path) &&
               Path.GetFullPath(componentPath).Equals(Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase);
    }

    static IEnumerable<string> ReadNeighborRelationFaceRefs(JsonElement relation, JsonElement package, JsonElement component)
    {
        foreach (var side in new[] { "A", "B" })
        {
            if (!RelationSideMatchesComponent(relation, side, package, component))
                continue;

            var componentName = ReadString(relation, $"Component{side}");
            var face = ReadInt(relation, $"Face{side}");
            if (!string.IsNullOrWhiteSpace(componentName) && face > 0)
                yield return $"{componentName}:face#{face}";

            foreach (var value in ReadSideStringValues(relation, side))
            {
                if (value.Contains("face#", StringComparison.OrdinalIgnoreCase))
                    yield return value;
            }
        }
    }

    static IEnumerable<string> ReadSideStringValues(JsonElement element, string side)
    {
        if (element.ValueKind != JsonValueKind.Object)
            yield break;

        foreach (var property in element.EnumerateObject())
        {
            if (!property.Name.EndsWith(side, StringComparison.OrdinalIgnoreCase) &&
                !property.Name.Contains($"Face{side}", StringComparison.OrdinalIgnoreCase) &&
                !property.Name.Contains($"Entity{side}", StringComparison.OrdinalIgnoreCase) &&
                !property.Name.Contains($"Reference{side}", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            if (property.Value.ValueKind == JsonValueKind.String)
            {
                yield return property.Value.GetString() ?? "";
            }
            else if (property.Value.ValueKind == JsonValueKind.Array)
            {
                foreach (var item in property.Value.EnumerateArray())
                {
                    if (item.ValueKind == JsonValueKind.String)
                        yield return item.GetString() ?? "";
                }
            }
        }
    }

    static object? BuildNeighborImagePlanRef(JsonElement partImagePlan)
    {
        if (partImagePlan.ValueKind == JsonValueKind.Undefined)
            return null;

        return new
        {
            component_id = ReadString(partImagePlan, "ComponentId"),
            part_image_plan_id = FirstNonEmpty(ReadString(partImagePlan, "PartImagePlanId"), ReadString(partImagePlan, "Id")),
            output_paths = CollectStringProperties(partImagePlan, "OutputPath")
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
                .ToList()
        };
    }

    static List<string> CollectStringProperties(JsonElement element, string propertyName)
    {
        var values = new List<string>();
        CollectStringProperties(element, propertyName, values);
        return values;
    }

    static void CollectStringProperties(JsonElement element, string propertyName, List<string> values)
    {
        if (element.ValueKind == JsonValueKind.Object)
        {
            foreach (var property in element.EnumerateObject())
            {
                if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase) &&
                    property.Value.ValueKind == JsonValueKind.String)
                {
                    var value = property.Value.GetString();
                    if (!string.IsNullOrWhiteSpace(value))
                        values.Add(value);
                }

                CollectStringProperties(property.Value, propertyName, values);
            }
        }
        else if (element.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in element.EnumerateArray())
                CollectStringProperties(item, propertyName, values);
        }
    }

    static bool ImageBelongsToComponent(JsonElement image, string componentId, JsonElement package)
    {
        var imageKind = ReadString(image, "ImageKind");
        var isDirectPartPackage =
            ReadString(package, "BrepSource").Equals("part_brep_direct", StringComparison.OrdinalIgnoreCase) ||
            ReadString(package, "ImageSource").Equals("part_file_direct_views", StringComparison.OrdinalIgnoreCase);
        if (isDirectPartPackage &&
            (imageKind.Equals("standard_model_view", StringComparison.OrdinalIgnoreCase) ||
             imageKind.Equals("section_model_view", StringComparison.OrdinalIgnoreCase)))
        {
            return true;
        }

        var text = $"{ReadString(image, "HighlightComponentId")} {ReadString(image, "HighlightInterfaceComponentAId")} {ReadString(image, "HighlightInterfaceComponentBId")} {ReadString(image, "HighlightPlanId")} {ReadString(image, "OutputPath")} {ReadString(image, "ViewName")}";
        return text.Contains(componentId, StringComparison.OrdinalIgnoreCase) ||
               text.Contains(ReadString(package, "AssemblyContextImagePlanId"), StringComparison.OrdinalIgnoreCase) ||
               text.Contains(ReadString(package, "PartImagePlanId"), StringComparison.OrdinalIgnoreCase);
    }

    static JsonElement BuildImageEvidenceRef(string outputDir, JsonElement image)
    {
        var outputPath = ReadString(image, "OutputPath");
        var relative = !string.IsNullOrWhiteSpace(outputPath) && Path.IsPathFullyQualified(outputPath)
            ? Path.GetRelativePath(outputDir, outputPath).Replace('\\', '/')
            : outputPath.Replace('\\', '/');
        return JsonSerializer.SerializeToElement(new
        {
            evidence_name = string.IsNullOrWhiteSpace(relative) ? ReadString(image, "ViewName") : relative,
            image_kind = ReadString(image, "ImageKind"),
            evidence_family = ReadString(image, "EvidenceFamily"),
            evidence_purpose = ReadString(image, "EvidencePurpose"),
            position_visibility = ReadString(image, "PositionVisibility"),
            view_name = ReadString(image, "ViewName"),
            requested_view = ReadString(image, "RequestedView"),
            highlight_plan_id = ReadString(image, "HighlightPlanId"),
            highlight_component_id = ReadString(image, "HighlightComponentId"),
            highlight_feature_id = ReadString(image, "HighlightFeatureId"),
            highlight_feature_type = ReadString(image, "HighlightFeatureType"),
            highlight_face_refs = ReadStringArrayValue(image, "HighlightFaceRefs"),
            physical_interface_id = ReadString(image, "HighlightInterfaceId"),
            source_contact_ids = ReadStringArrayValue(image, "HighlightInterfaceSourceContactIds"),
            interface_axis_mm = ReadDoubleArray(image, "HighlightInterfaceAxisMm"),
            interface_center_mm = ReadDoubleArray(image, "HighlightInterfaceCenterMm"),
            interface_bbox_mm = ReadDoubleArray(image, "HighlightInterfaceBBoxMm"),
            interface_analysis_priority = ReadNullableNumber(image, "InterfaceAnalysisPriority"),
            interface_confidence_tier = ReadString(image, "InterfaceConfidenceTier"),
            interface_evidence_purpose = ReadString(image, "InterfaceEvidencePurpose"),
            trimmed_patch_verified = ReadBoolean(image, "InterfaceTrimmedPatchVerified"),
            interface_verification_status = ReadString(image, "InterfaceVerificationStatus"),
            interface_verification_methods = ReadStringArrayValue(image, "InterfaceVerificationMethods"),
            interface_verification_sample_hits = ReadInt(image, "InterfaceVerificationSampleHits"),
            interface_verification_min_distance_mm = ReadNullableNumber(image, "InterfaceVerificationMinDistanceMm"),
            interface_display_mode = ReadString(image, "InterfaceDisplayMode"),
            actual_assembly_position = ReadBoolean(image, "InterfaceActualAssemblyPosition"),
            not_actual_clearance = ReadBoolean(image, "InterfaceNotActualClearance"),
            highlight_view_direction_mm = ReadDoubleArray(image, "HighlightViewDirectionMm"),
            highlight_blocking_face_refs = ReadStringArrayValue(image, "HighlightBlockingFaceRefs"),
            output_path = outputPath
        });
    }

    static Dictionary<string, int> CountLocalUnitAnchorTypes(IReadOnlyList<JsonElement> units)
    {
        var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        foreach (var unit in units)
        {
            var anchorType = NormalizeAnchorType(FirstNonEmpty(ReadString(unit, "anchor_type"), InferAnchorType(unit)));
            if (string.IsNullOrWhiteSpace(anchorType))
                continue;
            counts[anchorType] = counts.GetValueOrDefault(anchorType) + 1;
        }

        return counts;
    }

    static List<string> LocalUnitAnchorIds(IReadOnlyList<JsonElement> units, string anchorType)
    {
        return units
            .Where(unit => NormalizeAnchorType(ReadString(unit, "anchor_type")).Equals(anchorType, StringComparison.OrdinalIgnoreCase))
            .Select(unit => FirstNonEmpty(ReadString(unit, "anchor_id"), ReadFactBundleAnchorId(unit)))
            .Where(id => !string.IsNullOrWhiteSpace(id))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    static string ReadFactBundleAnchorId(JsonElement unit)
    {
        return unit.TryGetProperty("fact_bundle", out var factBundle) &&
               factBundle.ValueKind == JsonValueKind.Object &&
               factBundle.TryGetProperty("anchor", out var anchor) &&
               anchor.ValueKind == JsonValueKind.Object
            ? ReadString(anchor, "id")
            : "";
    }

    static string ReadModelPathFromStructure(string structureGraphPath)
    {
        try
        {
            using var doc = JsonDocument.Parse(File.ReadAllText(structureGraphPath));
            if (TryGetPath(doc.RootElement, out var path, "Source", "AssemblyPath") && path.ValueKind == JsonValueKind.String)
                return path.GetString() ?? "";
        }
        catch
        {
            return "";
        }

        return "";
    }

    static string JsonEscape(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);

    static string SanitizeFileName(string value)
    {
        var invalid = Path.GetInvalidFileNameChars().ToHashSet();
        var chars = value.Select(c => invalid.Contains(c) ? '_' : c).ToArray();
        string result = new(chars);
        return string.IsNullOrWhiteSpace(result) ? "component" : result;
    }

    static string FirstNonEmpty(params string?[] values)
    {
        foreach (var value in values)
        {
            if (!string.IsNullOrWhiteSpace(value))
                return value.Trim();
        }

        return "";
    }

    static int ReadInt(string? value, int fallback, int min, int max)
    {
        return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
            ? Math.Clamp(parsed, min, max)
            : fallback;
    }

    static string NormalizeBaseUrl(string value)
    {
        var trimmed = string.IsNullOrWhiteSpace(value) ? "https://dashscope.aliyuncs.com/compatible-mode/v1" : value.Trim();
        return trimmed.TrimEnd('/');
    }

    static string NormalizeApiMode(string value)
    {
        var trimmed = FirstNonEmpty(value, "chat_completions").ToLowerInvariant();
        return trimmed is "responses" ? "responses" : "chat_completions";
    }

    static object BuildResponsesRequest(string model, string prompt)
    {
        return new
        {
            model,
            input = new object[]
            {
                new
                {
                    role = "user",
                    content = new object[]
                    {
                        new
                        {
                            type = "input_text",
                            text = prompt
                        }
                    }
                }
            }
        };
    }

    static object BuildResponsesRequest(string model, string prompt, IReadOnlyList<string> imagePaths)
    {
        if (imagePaths.Count == 0)
            return BuildResponsesRequest(model, prompt);

        var content = new List<object>
        {
            new
            {
                type = "input_text",
                text = prompt + "\n\nAttached images were selected by evidence_family for this stage. Do not use an image outside the stage-specific evidence policy."
            }
        };
        foreach (var path in imagePaths)
        {
            content.Add(new
            {
                type = "input_image",
                image_url = ToDataUri(path)
            });
        }

        return new
        {
            model,
            input = new object[]
            {
                new
                {
                    role = "user",
                    content
                }
            }
        };
    }

    static object BuildChatCompletionsRequest(string model, string prompt, IReadOnlyList<string> imagePaths)
    {
        if (imagePaths.Count == 0)
        {
            return new
            {
                model,
                messages = new object[]
                {
                    new
                    {
                        role = "user",
                        content = prompt
                    }
                }
            };
        }

        var content = new List<object>
        {
            new
            {
                type = "text",
                text = prompt + "\n\nAttached images come from SolidWorks standard views, component context views, part views, and section views. Cross-check image evidence with B-rep JSON; do not conclude from images alone."
            }
        };
        foreach (var path in imagePaths)
        {
            content.Add(new
            {
                type = "image_url",
                image_url = new
                {
                    url = ToDataUri(path)
                }
            });
        }

        return new
        {
            model,
            messages = new object[]
            {
                new
                {
                    role = "user",
                    content
                }
            }
        };
    }

    static List<string> CollectImagePaths(string outputDir, int maxImages)
    {
        if (maxImages <= 0 || !Directory.Exists(outputDir))
            return [];

        var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
        return Directory.EnumerateFiles(outputDir, "*.*", SearchOption.AllDirectories)
            .Where(path => allowed.Contains(Path.GetExtension(path)))
            .Select(path => new FileInfo(path))
            .Where(file => file.Exists && file.Length > 0 && file.Length <= 5 * 1024 * 1024)
            .GroupBy(file => ImageDedupeKey(file.FullName), StringComparer.OrdinalIgnoreCase)
            .Select(group => group
                .OrderBy(file => ImagePriority(file.FullName))
                .ThenBy(file => file.Length)
                .ThenBy(file => file.FullName, StringComparer.OrdinalIgnoreCase)
                .First())
            .OrderBy(file => ImagePriority(file.FullName))
            .ThenBy(file => file.FullName, StringComparer.OrdinalIgnoreCase)
            .Take(maxImages)
            .Select(file => file.FullName)
            .ToList();
    }

    static string ImageDedupeKey(string path)
    {
        var name = Path.GetFileNameWithoutExtension(path).ToLowerInvariant();
        if (name.StartsWith("section_", StringComparison.OrdinalIgnoreCase))
            return $"section:{name}";
        if (name is "front" or "back" or "top" or "bottom" or "left" or "right" or "isometric")
            return $"standard:{name}";
        return $"{name}:{new FileInfo(path).Length / 4096}";
    }

    static int ImagePriority(string path)
    {
        var name = Path.GetFileName(path).ToLowerInvariant();
        var full = path.ToLowerInvariant();
        if (name.Contains("isometric") || name.Contains("iso")) return 0;
        if (full.Contains("section")) return 1;
        if (name.Contains("front") || name.Contains("top") || name.Contains("right")) return 2;
        return 3;
    }

    static string ToDataUri(string path)
    {
        return $"data:{MimeType(path)};base64,{Convert.ToBase64String(File.ReadAllBytes(path))}";
    }

    static string MimeType(string path)
    {
        return Path.GetExtension(path).ToLowerInvariant() switch
        {
            ".jpg" or ".jpeg" => "image/jpeg",
            ".png" => "image/png",
            ".webp" => "image/webp",
            _ => "application/octet-stream"
        };
    }

    static List<string> ValidateRetrievalContract(string? semanticGraphJson)
    {
        var issues = new List<string>();
        if (string.IsNullOrWhiteSpace(semanticGraphJson))
        {
            issues.Add("missing mechanical_semantic_graph_v1 JSON block");
            return issues;
        }

        try
        {
            using var doc = JsonDocument.Parse(semanticGraphJson);
            var root = doc.RootElement;
            if (!root.TryGetProperty("local_semantic_units", out var units) || units.ValueKind != JsonValueKind.Array || units.GetArrayLength() == 0)
            {
                issues.Add("local_semantic_units is missing or empty");
            }
            else
            {
                var seen = 0;
                var dedupKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                foreach (var unit in units.EnumerateArray())
                {
                    seen++;
                    var id = FirstNonEmpty(ReadString(unit, "local_id"), ReadString(unit, "id"), $"#{seen}");
                    var primaryView = ReadString(unit, "primary_view");
                    var retrievalSentence = ReadString(unit, "retrieval_sentence");
                    var dedupKey = ReadString(unit, "dedup_key");
                    var anchorType = ReadString(unit, "anchor_type");
                    if (string.IsNullOrWhiteSpace(primaryView))
                        issues.Add($"local_semantic_units[{id}] missing primary_view");
                    if (string.IsNullOrWhiteSpace(retrievalSentence))
                        issues.Add($"local_semantic_units[{id}] missing retrieval_sentence");
                    if (string.IsNullOrWhiteSpace(anchorType))
                        issues.Add($"local_semantic_units[{id}] missing anchor_type");
                    else if (!IsAllowedAnchorType(anchorType))
                        issues.Add($"local_semantic_units[{id}] invalid anchor_type: {anchorType}");
                    if (string.IsNullOrWhiteSpace(ReadString(unit, "anchor_id")) && !HasFactBundleAnchor(unit))
                        issues.Add($"local_semantic_units[{id}] missing anchor_id or fact_bundle.anchor.id");
                    if (string.IsNullOrWhiteSpace(dedupKey))
                        issues.Add($"local_semantic_units[{id}] missing dedup_key");
                    else if (!dedupKeys.Add(dedupKey))
                        issues.Add($"local_semantic_units[{id}] duplicate dedup_key: {dedupKey}");
                    if (HasJudgementWords(retrievalSentence))
                        issues.Add($"local_semantic_units[{id}] retrieval_sentence contains judgement words");
                    if (HasTechnicalBrepReference(retrievalSentence))
                        issues.Add($"local_semantic_units[{id}] retrieval_sentence contains raw B-rep ids; use human-readable mechanical semantics instead");
                    if (!unit.TryGetProperty("retrieval_chain", out var retrievalChain) || retrievalChain.ValueKind != JsonValueKind.Object)
                        issues.Add($"local_semantic_units[{id}] missing retrieval_chain object");
                    else
                    {
                        if (string.IsNullOrWhiteSpace(ReadString(retrievalChain, "geometric_fact_for_retrieval")))
                            issues.Add($"local_semantic_units[{id}] missing retrieval_chain.geometric_fact_for_retrieval");
                        if (string.IsNullOrWhiteSpace(ReadString(retrievalChain, "mechanical_role")))
                            issues.Add($"local_semantic_units[{id}] missing retrieval_chain.mechanical_role");
                        if (string.IsNullOrWhiteSpace(ReadString(retrievalChain, "process_assembly_maintenance_semantics")))
                            issues.Add($"local_semantic_units[{id}] missing retrieval_chain.process_assembly_maintenance_semantics");
                    }
                    if (!unit.TryGetProperty("fact_bundle", out var factBundle) || factBundle.ValueKind != JsonValueKind.Object)
                        issues.Add($"local_semantic_units[{id}] missing fact_bundle object");
                    if (ReadStringArray(unit, "secondary_views") is null)
                        issues.Add($"local_semantic_units[{id}] secondary_views must be an array");
                    if (CountArray(unit, "image_evidence_names") == 0 && CountArray(factBundle, "image_evidence_names") == 0)
                        issues.Add($"local_semantic_units[{id}] missing image_evidence_names; cite exact component highlight, part, or context image names");
                }
            }

            if (!root.TryGetProperty("local_subgraphs", out var subgraphs) || subgraphs.ValueKind != JsonValueKind.Array || subgraphs.GetArrayLength() == 0)
            {
                issues.Add("local_subgraphs is missing or empty");
            }
            else if (root.TryGetProperty("local_semantic_units", out var unitsForSubgraphRefs) &&
                     unitsForSubgraphRefs.ValueKind == JsonValueKind.Array)
            {
                var subgraphIds = subgraphs.EnumerateArray()
                    .Select(item => ReadString(item, "id"))
                    .Where(id => !string.IsNullOrWhiteSpace(id))
                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
                var linkedSubgraphIds = unitsForSubgraphRefs.EnumerateArray()
                    .Select(item => ReadString(item, "linked_subgraph_id"))
                    .Where(id => !string.IsNullOrWhiteSpace(id))
                    .Distinct(StringComparer.OrdinalIgnoreCase);
                foreach (var linkedSubgraphId in linkedSubgraphIds)
                {
                    if (!subgraphIds.Contains(linkedSubgraphId))
                        issues.Add($"linked_subgraph_id '{linkedSubgraphId}' is referenced by local_semantic_units but missing from local_subgraphs");
                }
            }

            AddSemanticCoverageIssues(root, issues);
        }
        catch (Exception ex)
        {
            issues.Add($"mechanical_semantic_graph_v1 JSON cannot be parsed: {ex.Message}");
        }

        return issues;
    }

    static void AddSemanticCoverageIssues(JsonElement root, List<string> issues)
    {
        var partDesignComponentCount = CountPartDesignRequiredComponents(root);
        var localUnitCount = CountArray(root, "local_semantic_units");
        if (partDesignComponentCount > 0)
        {
            var minimumExpectedUnits = partDesignComponentCount >= 8
                ? Math.Min(partDesignComponentCount, 12)
                : partDesignComponentCount;
            if (localUnitCount < minimumExpectedUnits)
            {
                issues.Add($"local_semantic_units coverage is too low: {localUnitCount} units for {partDesignComponentCount} part_design_required component instances; expected at least {minimumExpectedUnits} before feature/interface/group units");
            }
        }

        if (!root.TryGetProperty("semantic_coverage_audit", out var audit) || audit.ValueKind != JsonValueKind.Object)
        {
            issues.Add("semantic_coverage_audit is missing");
            return;
        }

        if (partDesignComponentCount > 0)
        {
            var coveredParts = CountArray(audit, "covered_part_component_ids");
            var uncoveredParts = CountUncoveredPartAnchors(audit);
            if (coveredParts + uncoveredParts < partDesignComponentCount)
            {
                issues.Add($"semantic_coverage_audit does not account for all part_design_required components: covered={coveredParts}, uncovered_parts={uncoveredParts}, expected={partDesignComponentCount}");
            }
        }

        if (!audit.TryGetProperty("local_unit_count_by_anchor_type", out var byAnchor) || byAnchor.ValueKind != JsonValueKind.Object)
        {
            issues.Add("semantic_coverage_audit.local_unit_count_by_anchor_type is missing");
        }
        else
        {
            var actual = CountLocalUnitsByAnchorType(root);
            foreach (var anchorType in new[] { "part", "feature", "interface", "functional_group" })
            {
                var declared = ReadInt(byAnchor, anchorType);
                actual.TryGetValue(anchorType, out var actualCount);
                if (declared != actualCount)
                    issues.Add($"semantic_coverage_audit.local_unit_count_by_anchor_type.{anchorType}={declared} does not match actual local_semantic_units count {actualCount}");
            }

            var candidateFunctionalGroups = CountArray(audit, "candidate_functional_group_ids");
            if (candidateFunctionalGroups > 0 &&
                actual.GetValueOrDefault("functional_group") + actual.GetValueOrDefault("interface") == 0)
            {
                issues.Add($"functional-group coverage is missing: {candidateFunctionalGroups} candidate functional groups exist, but no functional_group or interface local_semantic_units were generated");
            }
        }
    }

    static Dictionary<string, int> CountLocalUnitsByAnchorType(JsonElement root)
    {
        var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        if (!root.TryGetProperty("local_semantic_units", out var units) || units.ValueKind != JsonValueKind.Array)
            return counts;

        foreach (var unit in units.EnumerateArray())
        {
            var anchorType = NormalizeAnchorType(FirstNonEmpty(ReadString(unit, "anchor_type"), InferAnchorType(unit)));
            if (string.IsNullOrWhiteSpace(anchorType))
                continue;
            counts[anchorType] = counts.TryGetValue(anchorType, out var count) ? count + 1 : 1;
        }

        return counts;
    }

    static string InferAnchorType(JsonElement unit)
    {
        var text = $"{ReadString(unit, "local_id")} {ReadString(unit, "pattern_type")} {ReadString(unit, "dedup_key")}".ToLowerInvariant();
        if (text.Contains("part")) return "part";
        if (text.Contains("interface") || text.Contains("contact") || text.Contains("mate") || text.Contains("fit")) return "interface";
        if (text.Contains("group") || text.Contains("functional")) return "functional_group";
        if (text.Contains("feature") || text.Contains("hole") || text.Contains("boss") || text.Contains("slot")) return "feature";
        return "";
    }

    static string NormalizeAnchorType(string value)
    {
        var text = value.Trim().ToLowerInvariant();
        return text switch
        {
            "component" => "part",
            "part" => "part",
            "feature" => "feature",
            "face_group" => "feature",
            "relation" => "interface",
            "interface" => "interface",
            "functional_group" => "functional_group",
            "group" => "functional_group",
            _ => text
        };
    }

    static bool IsAllowedAnchorType(string value)
    {
        var normalized = NormalizeAnchorType(value);
        return normalized is "part" or "feature" or "interface" or "functional_group";
    }

    static bool HasFactBundleAnchor(JsonElement unit)
    {
        return unit.TryGetProperty("fact_bundle", out var factBundle) &&
               factBundle.ValueKind == JsonValueKind.Object &&
               factBundle.TryGetProperty("anchor", out var anchor) &&
               anchor.ValueKind == JsonValueKind.Object &&
               !string.IsNullOrWhiteSpace(ReadString(anchor, "id"));
    }

    static int ReadInt(JsonElement element, string property)
    {
        if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
            return 0;
        if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
            return number;
        if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
            return parsed;
        return 0;
    }

    static int CountPartDesignRequiredComponents(JsonElement root)
    {
        if (!root.TryGetProperty("nodes", out var nodes) || nodes.ValueKind != JsonValueKind.Array)
            return 0;

        var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var node in nodes.EnumerateArray())
        {
            if (!string.Equals(ReadString(node, "type"), "component", StringComparison.OrdinalIgnoreCase))
                continue;
            if (!string.Equals(ReadString(node, "review_scope"), "part_design_required", StringComparison.OrdinalIgnoreCase))
                continue;

            var id = FirstNonEmpty(ReadString(node, "id"), ReadString(node, "display_name"));
            if (!string.IsNullOrWhiteSpace(id))
                ids.Add(id);
        }

        return ids.Count;
    }

    static int CountUncoveredPartAnchors(JsonElement audit)
    {
        if (!audit.TryGetProperty("uncovered_candidate_anchors", out var anchors) || anchors.ValueKind != JsonValueKind.Array)
            return 0;

        var count = 0;
        foreach (var anchor in anchors.EnumerateArray())
        {
            var anchorType = ReadString(anchor, "anchor_type");
            if (anchorType.Equals("part", StringComparison.OrdinalIgnoreCase) ||
                anchorType.Equals("component", StringComparison.OrdinalIgnoreCase))
            {
                count++;
            }
        }

        return count;
    }

    static int CountArray(JsonElement element, string property)
    {
        return element.ValueKind == JsonValueKind.Object &&
               element.TryGetProperty(property, out var value) &&
               value.ValueKind == JsonValueKind.Array
            ? value.GetArrayLength()
            : 0;
    }

    static string BuildRetrievalContractRepairPrompt(string originalPrompt, string previousAnswer, IReadOnlyList<string> issues)
    {
        if (previousAnswer.Length > 80000)
            previousAnswer = previousAnswer[..80000] + "\n...TRUNCATED_PREVIOUS_ANSWER...";

        return originalPrompt + "\n\nThe previous answer did not satisfy the mechanical diagnostic retrieval contract.\n"
            + "Fix only the JSON output. Return the two required fenced JSON blocks again.\n"
            + "The mechanical_semantic_graph_v1 block must include non-empty local_semantic_units and local_subgraphs.\n"
            + "Do not collapse the model into a few generic local units. Split local_semantic_units by part, feature, interface, and functional_group anchors, and add semantic_coverage_audit.\n"
            + "Every nonstandard component instance with review_scope=part_design_required must have at least one part-level local unit unless semantic_coverage_audit.uncovered_candidate_anchors explains why it is intentionally excluded.\n"
            + "semantic_coverage_audit must match the actual local_semantic_units. Do not claim covered_part_component_ids or covered_feature_anchor_ids unless actual local units cite those anchors.\n"
            + "Each local_semantic_units item must include anchor_type, anchor_id, and non-empty image_evidence_names when a component-specific image exists.\n"
            + "Each local_semantic_units item must have local_id, pattern_type, primary_view, secondary_views, retrieval_sentence, retrieval_chain, retrieval_semantic_roles, fact_bundle, and dedup_key.\n"
            + "retrieval_chain must include geometric_fact_for_retrieval, mechanical_role, and process_assembly_maintenance_semantics.\n"
            + "When evidence supports them, include inferred_functions, inferred_attributes, retrieval_fact_tags, retrieval_relations, and normalized_measurements for retrieval enrichment.\n"
            + "retrieval_sentence must be one neutral Chinese fact sentence, with no rule id and no fault judgement.\n"
            + "Issues to fix:\n- " + string.Join("\n- ", issues) + "\n\nPrevious answer:\n" + previousAnswer;
    }

    static bool HasJudgementWords(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return false;

        var text = value.ToLowerInvariant();
        return text.Contains("error") ||
               text.Contains("violation") ||
               text.Contains("fault") ||
               text.Contains("wrong") ||
               text.Contains("should") ||
               text.Contains("閿欒") ||
               text.Contains("杩濆弽") ||
               text.Contains("搴旇") ||
               text.Contains("涓嶅簲");
    }

    static bool HasTechnicalBrepReference(string value)
    {
        return TechnicalBrepReferenceRegex.IsMatch(value);
    }

    static readonly Regex TechnicalBrepReferenceRegex = new(
        @"\b(?:sketch_edge|edge|face|vertex|patch|region|measurement|relation)_[A-Za-z0-9_.-]+\b|(?:sketch_edge|edge|face|vertex|patch|region|measurement|relation)_\d+",
        RegexOptions.IgnoreCase | RegexOptions.Compiled);

    static string ReadString(JsonElement element, string property)
    {
        return element.ValueKind == JsonValueKind.Object &&
               element.TryGetProperty(property, out var value) &&
               value.ValueKind == JsonValueKind.String
            ? value.GetString() ?? ""
            : "";
    }

    static bool ReadBoolean(JsonElement element, string property)
    {
        return element.ValueKind == JsonValueKind.Object &&
               element.TryGetProperty(property, out var value) &&
               value.ValueKind is JsonValueKind.True or JsonValueKind.False &&
               value.GetBoolean();
    }

    static List<string>? ReadStringArray(JsonElement element, string property)
    {
        if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
            return [];
        if (value.ValueKind != JsonValueKind.Array)
            return null;

        return value.EnumerateArray()
            .Where(item => item.ValueKind == JsonValueKind.String)
            .Select(item => item.GetString() ?? "")
            .Where(item => item.Length > 0)
            .ToList();
    }

    static List<double> ReadDoubleArray(JsonElement element, string property)
    {
        if (element.ValueKind != JsonValueKind.Object ||
            !element.TryGetProperty(property, out var value) ||
            value.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        return value.EnumerateArray()
            .Where(item => item.ValueKind == JsonValueKind.Number)
            .Select(item => item.TryGetDouble(out var number) ? number : 0)
            .ToList();
    }

    static string ExtractResponseText(string raw)
    {
        using var doc = JsonDocument.Parse(raw);
        if (doc.RootElement.TryGetProperty("output_text", out var outputText) && outputText.ValueKind == JsonValueKind.String)
            return outputText.GetString() ?? "";

        var parts = new List<string>();
        if (doc.RootElement.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array)
        {
            foreach (var choice in choices.EnumerateArray())
            {
                if (!choice.TryGetProperty("message", out var message))
                    continue;
                if (message.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.String)
                    parts.Add(content.GetString() ?? "");
            }
            if (parts.Count > 0)
                return string.Join(Environment.NewLine, parts);
        }

        if (doc.RootElement.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in output.EnumerateArray())
            {
                if (!item.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
                    continue;
                foreach (var c in content.EnumerateArray())
                {
                    if (c.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
                        parts.Add(text.GetString() ?? "");
                }
            }
        }

        return parts.Count > 0 ? string.Join(Environment.NewLine, parts) : raw;
    }

    static AiSelfEvolutionRequest? ExtractSelfEvolutionRequest(string text)
    {
        string? json = ExtractJsonObjectWithSchema(text, "self_evolution_request_v1") ?? ExtractLegacySelfEvolutionJsonObject(text);
        if (string.IsNullOrWhiteSpace(json))
            return null;

        try
        {
            using var doc = JsonDocument.Parse(json);
            var root = doc.RootElement;
            return new AiSelfEvolutionRequest
            {
                Confidence = root.TryGetProperty("confidence", out var c) && c.ValueKind == JsonValueKind.Number ? c.GetDouble() : null,
                NeedsMoreFacts = root.TryGetProperty("needs_more_facts", out var n) && n.ValueKind is JsonValueKind.True or JsonValueKind.False && n.GetBoolean(),
                MissingFacts = root.TryGetProperty("missing_facts", out var m) && m.ValueKind == JsonValueKind.Array
                    ? m.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => x.Length > 0).ToList()
                    : [],
                SupportedNextActions = root.TryGetProperty("supported_next_actions", out var a) && a.ValueKind == JsonValueKind.Array
                    ? a.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => x.Length > 0).ToList()
                    : [],
                Reason = root.TryGetProperty("reason", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() ?? "" : ""
            };
        }
        catch
        {
            return null;
        }
    }

    static string? ExtractJsonObjectWithSchema(string text, string schema)
    {
        foreach (string block in ExtractFencedJsonBlocks(text))
        {
            try
            {
                using var doc = JsonDocument.Parse(block);
                if (doc.RootElement.ValueKind == JsonValueKind.Object &&
                    doc.RootElement.TryGetProperty("schema", out var s) &&
                    s.ValueKind == JsonValueKind.String &&
                    string.Equals(s.GetString(), schema, StringComparison.OrdinalIgnoreCase))
                {
                    return block;
                }
            }
            catch
            {
                var repaired = RepairMalformedAiJsonBlock(block);
                if (!string.Equals(repaired, block, StringComparison.Ordinal))
                {
                    try
                    {
                        using var doc = JsonDocument.Parse(repaired);
                        if (doc.RootElement.ValueKind == JsonValueKind.Object &&
                            doc.RootElement.TryGetProperty("schema", out var s) &&
                            s.ValueKind == JsonValueKind.String &&
                            string.Equals(s.GetString(), schema, StringComparison.OrdinalIgnoreCase))
                        {
                            return repaired;
                        }
                    }
                    catch
                    {
                        // Ignore malformed fenced blocks and continue searching.
                    }
                }
            }
        }

        return null;
    }

    static string RepairMalformedAiJsonBlock(string block)
    {
        return Regex.Replace(
            block,
            @"""inferred_attributes""\s*:\s*\[\s*""([^""]+)""\s*:\s*(\[[^\]]*\]|[^,\]]+)\s*,\s*""([^""]+)""\s*:\s*(\[[^\]]*\]|[^,\]]+)\s*\]",
            @"""inferred_attributes"": { ""$1"": $2, ""$3"": $4 }",
            RegexOptions.CultureInvariant);
    }

    static string? ExtractLegacySelfEvolutionJsonObject(string text)
    {
        foreach (string block in ExtractFencedJsonBlocks(text).AsEnumerable().Reverse())
        {
            try
            {
                using var doc = JsonDocument.Parse(block);
                if (doc.RootElement.ValueKind != JsonValueKind.Object)
                    continue;
                if (doc.RootElement.TryGetProperty("schema", out var schema) &&
                    schema.ValueKind == JsonValueKind.String &&
                    !string.IsNullOrWhiteSpace(schema.GetString()))
                {
                    continue;
                }
                if (doc.RootElement.TryGetProperty("needs_more_facts", out _) ||
                    doc.RootElement.TryGetProperty("missing_facts", out _) ||
                    doc.RootElement.TryGetProperty("supported_next_actions", out _))
                {
                    return block;
                }
            }
            catch
            {
                // Ignore malformed fenced blocks and continue searching.
            }
        }

        return null;
    }

    static List<string> ExtractFencedJsonBlocks(string text)
    {
        var blocks = new List<string>();
        int index = 0;
        while (index < text.Length)
        {
            int fence = text.IndexOf("```json", index, StringComparison.OrdinalIgnoreCase);
            if (fence < 0)
                break;

            int start = text.IndexOf('{', fence);
            if (start < 0)
                break;

            int endFence = text.IndexOf("```", start + 1, StringComparison.Ordinal);
            if (endFence < 0)
                break;

            blocks.Add(text[start..endFence].Trim());
            index = endFence + 3;
        }

        return blocks;
    }

    static string NormalizeJson(string json)
    {
        using var doc = JsonDocument.Parse(json);
        return JsonSerializer.Serialize(doc.RootElement, JsonOptions.Default);
    }
}

sealed class ComponentPromptPlan
{
    public string ComponentId { get; set; } = "";
    public string InstanceName { get; set; } = "";
    public string DisplayName { get; set; } = "";
    public string DocumentKind { get; set; } = "";
    public bool IsPartDocument => DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase);
    public string DiagnosticStage => IsPartDocument ? DiagnosticFlowTerms.StageSinglePartCheck : DiagnosticFlowTerms.StageGroupInternalPartCheck;
    public string KnowledgeScope => DiagnosticFlowTerms.KnowledgeScopePart;
    public string EvidenceDirectory { get; set; } = "";
    public string EvidencePath { get; set; } = "";
    public JsonElement Component { get; set; }
    public JsonElement Package { get; set; }
    public JsonElement PartImagePlan { get; set; }
    public List<JsonElement> Features { get; set; } = [];
    public List<JsonElement> Faces { get; set; } = [];
    public List<JsonElement> Contacts { get; set; } = [];
    public List<JsonElement> Mates { get; set; } = [];
    public List<JsonElement> ContextImagePlans { get; set; } = [];
    public List<JsonElement> AvailableImages { get; set; } = [];
    public JsonElement PartCheckEvidence { get; set; }
    public List<string> AdjacentComponentIds { get; set; } = [];
    public List<JsonElement> NeighborSummaries { get; set; } = [];
    public List<JsonElement> NeighborPackages { get; set; } = [];
    public List<JsonElement> NeighborComponents { get; set; } = [];
    public List<JsonElement> NeighborPartImagePlans { get; set; } = [];
    public string FunctionalBlockId { get; set; } = "";
    public string PartRole { get; set; } = "";
    public object PartFunctionProfile { get; set; } = new();
    public object FunctionalBlockContext { get; set; } = new();
    public JsonObject? AssemblyFunctionProfile { get; set; }
    public JsonObject FunctionalGroupContext { get; set; } = new()
    {
        ["schema"] = "component_functional_group_context_v1",
        ["selection_policy"] = "No functional-group relationship context has been assigned.",
        ["usage_policy"] = "No group-level relation evidence is available for this component.",
        ["groups"] = new JsonArray()
    };
    public string ProductDescription { get; set; } = "";
    public string UserPartFunctionProfile { get; set; } = "";
    public List<string> PurchasedComponentHints { get; set; } = [];
    public string StaticScoutRules { get; set; } = "";
    public object CoarseScoutScan { get; set; } = new();
    public string CoarseScoutScanPath { get; set; } = "";
    public string CoarseScoutCardQueryText { get; set; } = "";
    public List<object> MatchedInspectionCards { get; set; } = [];
    public List<CoverageSeedDraft> CoverageSeeds { get; set; } = [];
    public string BatchId { get; set; } = "";
    public int BatchIndex { get; set; }
    public int BatchCount { get; set; }
    public List<string> BatchFeatureIds { get; set; } = [];
}

sealed class CoverageSeedDraft
{
    public string SeedId { get; set; } = "";
    public string AnchorType { get; set; } = "";
    public string AnchorId { get; set; } = "";
    public string FeatureType { get; set; } = "";
    public string PrimaryView { get; set; } = "";
    public string InspectionFocus { get; set; } = "";
    public List<string> EvidenceRefs { get; set; } = [];
    public string GeometrySummary { get; set; } = "";
}

sealed class StaticScoutScanDraft
{
    public object Dto { get; set; } = new();
    public string CardQueryText { get; set; } = "";
}

sealed class DiagnosticContextArtifacts
{
    public string AssemblyStructureSummaryPath { get; set; } = "";
    public string FunctionalBlocksPath { get; set; } = "";
    public string PartFunctionProfilesPath { get; set; } = "";
    public string ComponentFunctionIntroductionsPath { get; set; } = "";
    public string ComponentFunctionIntroductionsMarkdownPath { get; set; } = "";
    public string FunctionalGroupRelationshipsPath { get; set; } = "";
    public string FunctionalGroupRelationshipsMarkdownPath { get; set; } = "";
    public string FunctionalGroupEvidenceManifestPath { get; set; } = "";
    public string FunctionalGroupDescriptionsPath { get; set; } = "";
    public string FunctionalGroupDescriptionsMarkdownPath { get; set; } = "";
    public string InterfaceAnchorAnalysisPath { get; set; } = "";
    public string InterfaceFunctionDescriptionsPath { get; set; } = "";
    public string InterfaceFunctionDescriptionsMarkdownPath { get; set; } = "";
    public string FunctionalGroupLocalSubgraphsPath { get; set; } = "";
    public string StaticScoutRulesPath { get; set; } = "";
    public string InspectionCardsPath { get; set; } = "";
    public string PartStaticScoutRulesPath { get; set; } = "";
    public string FunctionalGroupStaticScoutRulesPath { get; set; } = "";
    public string PartInspectionCardsPath { get; set; } = "";
    public string FunctionalGroupInspectionCardsPath { get; set; } = "";
}

sealed class InspectionCard
{
    public string RuleId { get; set; } = "";
    public string Title { get; set; } = "";
    public string ScopeType { get; set; } = "";
    public string SearchText { get; set; } = "";
    public object Dto { get; set; } = new();
}

sealed class FunctionContext
{
    public List<FunctionBlockDraft> Blocks { get; set; } = [];
    public List<PartFunctionProfileDraft> Profiles { get; set; } = [];
}

sealed class FunctionBlockDraft
{
    public string BlockId { get; set; } = "";
    public string Name { get; set; } = "";
    public string Function { get; set; } = "";
    public string Confidence { get; set; } = "";
    public List<string> ComponentIds { get; set; } = [];

    public object ToDto() => new
    {
        block_id = BlockId,
        name = Name,
        function = Function,
        component_ids = ComponentIds.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToList(),
        confidence = Confidence
    };
}

sealed class PartFunctionProfileDraft
{
    public string ComponentId { get; set; } = "";
    public string ComponentName { get; set; } = "";
    public string DisplayName { get; set; } = "";
    public string FunctionalBlockId { get; set; } = "";
    public string PartRole { get; set; } = "";
    public List<string> MainInterfaces { get; set; } = [];
    public string Confidence { get; set; } = "";
    public List<string> Evidence { get; set; } = [];

    public object ToDto() => new
    {
        component_id = ComponentId,
        component_name = ComponentName,
        display_name = DisplayName,
        functional_block_id = FunctionalBlockId,
        part_role = PartRole,
        main_interfaces = MainInterfaces,
        confidence = Confidence,
        evidence = Evidence
    };
}

sealed class ComponentAiResult
{
    public string ComponentId { get; set; } = "";
    public string InstanceName { get; set; } = "";
    public string DisplayName { get; set; } = "";
    public string BatchId { get; set; } = "";
    public int BatchIndex { get; set; }
    public int BatchCount { get; set; }
    public List<string> BatchFeatureIds { get; set; } = [];
    public string Status { get; set; } = "not_started";
    public string Message { get; set; } = "";
    public string PromptPath { get; set; } = "";
    public string EnrichedPromptPath { get; set; } = "";
    public string ResultPath { get; set; } = "";
    public string ResponseText { get; set; } = "";
    public string LocalSubgraphJson { get; set; } = "";
    public List<string> RawResponsePaths { get; set; } = [];
    public List<object> Iterations { get; set; } = [];
}

sealed class NeighborDetailRequest
{
    public bool NeedsNeighborDetail { get; set; }
    public List<string> ComponentIds { get; set; } = [];
    public string Reason { get; set; } = "";
}

sealed class BridgeArtifact
{
    public string StructureGraphPath { get; set; } = "";
    public string PatchMapPath { get; set; } = "";
    public string RenderSvgPath { get; set; } = "";
    public string LlmInputPath { get; set; } = "";
    public string PromptPath { get; set; } = "";
    public string MechanicalSemanticGraphContractPath { get; set; } = "";
    public string SelfEvolutionAuditPath { get; set; } = "";
    public SelfEvolutionAudit Audit { get; set; } = new();
    public string PromptText { get; set; } = "";
}

sealed class AiCallResult
{
    public string Status { get; set; } = "";
    public string Model { get; set; } = "";
    public string Message { get; set; } = "";
    public string ResultPath { get; set; } = "";
    public string RawResponsePath { get; set; } = "";
    public string SelfEvolutionPath { get; set; } = "";
    public string MechanicalSemanticGraphPath { get; set; } = "";
    public string MechanicalSemanticGraphStatus { get; set; } = "";
}

sealed class AiSelfEvolutionRequest
{
    public double? Confidence { get; set; }
    public bool NeedsMoreFacts { get; set; }
    public List<string> MissingFacts { get; set; } = [];
    public List<string> SupportedNextActions { get; set; } = [];
    public string Reason { get; set; } = "";
}

sealed class SelfEvolutionAudit
{
    public bool NeedsMoreFacts { get; set; }
    public double Confidence { get; set; }
    public List<string> Reasons { get; set; } = [];
    public List<string> SupportedNextActions { get; set; } = [];
    public Dictionary<string, object?> CurrentFactCoverage { get; set; } = [];
}

sealed class SectionViewReport
{
    public string Id { get; set; } = "";
    public double[] GuideAxis { get; set; } = [];
    public string ReportPath { get; set; } = "";
    public SectionBrepReport Report { get; set; } = new();
}

sealed record UserDiagnosticContext(string ProductDescription, List<string> PurchasedComponentHints, string PartFunctionProfile);
sealed record SectionAxisRequest(string Id, double[] GuideAxis);

sealed class ComponentRadiusFact
{
    public string ComponentName { get; set; } = "";
    public string Category { get; set; } = "";
    public int FaceIndex { get; set; }
    public double? RadiusMm { get; set; }
}

sealed class PatchNode
{
    public string Id { get; set; } = "";
    public int Row { get; set; }
    public int Column { get; set; }
    public object Bounds { get; set; } = new();
    public object NormalizedBounds { get; set; } = new();
    public List<string> EdgeIds { get; set; } = [];
    public List<string> Owners { get; set; } = [];
    public List<string> Roles { get; set; } = [];
}

sealed class Bounds
{
    public double MinX { get; set; } = double.PositiveInfinity;
    public double MaxX { get; set; } = double.NegativeInfinity;
    public double MinY { get; set; } = double.PositiveInfinity;
    public double MaxY { get; set; } = double.NegativeInfinity;
    public bool Valid => !double.IsInfinity(MinX) && !double.IsInfinity(MaxX) && !double.IsInfinity(MinY) && !double.IsInfinity(MaxY);
    public double Width => Math.Max(MaxX - MinX, 0.001);
    public double Height => Math.Max(MaxY - MinY, 0.001);

    public static Bounds FromEdges(IEnumerable<SketchDrawableEdge> edges)
    {
        return FromPoints(edges.SelectMany(e => new[] { SemanticBridgeBuilderPoint(e.Start), SemanticBridgeBuilderPoint(e.End) }));
    }

    public static Bounds FromPoints(IEnumerable<double[]> points)
    {
        var bounds = new Bounds();
        foreach (var p in points.Where(p => p.Length >= 2))
        {
            bounds.MinX = Math.Min(bounds.MinX, p[0]);
            bounds.MaxX = Math.Max(bounds.MaxX, p[0]);
            bounds.MinY = Math.Min(bounds.MinY, p[1]);
            bounds.MaxY = Math.Max(bounds.MaxY, p[1]);
        }

        if (bounds.Valid && bounds.Width < 0.001)
        {
            bounds.MinX -= 0.5;
            bounds.MaxX += 0.5;
        }
        if (bounds.Valid && bounds.Height < 0.001)
        {
            bounds.MinY -= 0.5;
            bounds.MaxY += 0.5;
        }
        return bounds;
    }

    public double[] Normalize(double[] point)
    {
        if (!Valid)
            return [0.0, 0.0];
        return
        [
            Math.Round((point[0] - MinX) / Width, 6),
            Math.Round((point[1] - MinY) / Height, 6)
        ];
    }

    public object ToDto()
    {
        return new
        {
            Min = new[] { Math.Round(MinX, 4), Math.Round(MinY, 4) },
            Max = new[] { Math.Round(MaxX, 4), Math.Round(MaxY, 4) },
            Width = Math.Round(Width, 4),
            Height = Math.Round(Height, 4)
        };
    }

    static double[] SemanticBridgeBuilderPoint(double[] point) => point.Length >= 2 ? [point[0], point[1]] : [0.0, 0.0];
}

sealed class SectionBrepReport
{
    public bool Ok { get; set; } = true;
    public string Path { get; set; } = "";
    public string Message { get; set; } = "";
    public string AxisGroupId { get; set; } = "";
    public SectionPlaneEvidence SectionPlane { get; set; } = new();
    public Canonical2DSketchGraph SketchGraph { get; set; } = new();
}

sealed class SectionPlaneEvidence
{
    public double[] OriginMm { get; set; } = [];
    public double[] AxisMm { get; set; } = [];
    public double[] PlaneNormalMm { get; set; } = [];
    public double[] UAxisMm { get; set; } = [];
    public double[] VAxisMm { get; set; } = [];
}

sealed class Canonical2DSketchGraph
{
    public string Schema { get; set; } = "canonical_2d_sketch_graph_v1";
    public Canonical2DSketchSource Source { get; set; } = new();
    public List<SketchVertex> Vertices { get; set; } = [];
    public List<SketchDrawableEdge> Edges { get; set; } = [];
    public List<SketchLoop> Loops { get; set; } = [];
    public List<SketchPath> Paths { get; set; } = [];
    public List<SketchRegion> Regions { get; set; } = [];
    public List<SketchRelation> Relations { get; set; } = [];
    public List<SketchDimension> Dimensions { get; set; } = [];
}

sealed class Canonical2DSketchSource
{
    public string Kind { get; set; } = "";
    public string Units { get; set; } = "";
}

sealed class SketchVertex
{
    public string Id { get; set; } = "";
}

sealed class SketchDrawableEdge
{
    public string Id { get; set; } = "";
    public List<string> SourceEdgeIds { get; set; } = [];
    public string StartVertex { get; set; } = "";
    public string EndVertex { get; set; } = "";
    public double[] Start { get; set; } = [];
    public double[] End { get; set; } = [];
    public string Owner { get; set; } = "";
    public string Role { get; set; } = "";
    public Dictionary<string, object?> Attributes { get; set; } = [];
}

sealed class SketchLoop
{
    public string Id { get; set; } = "";
}

sealed class SketchPath
{
    public string Id { get; set; } = "";
    public List<string> EdgeRefs { get; set; } = [];
}

sealed class SketchRegion
{
    public string Id { get; set; } = "";
    public string Owner { get; set; } = "";
    public string Role { get; set; } = "";
    public List<string> LoopRefs { get; set; } = [];
}

sealed class SketchRelation
{
    public string Type { get; set; } = "";
    public string A { get; set; } = "";
    public string B { get; set; } = "";
}

sealed class SketchDimension
{
    public string Kind { get; set; } = "";
    public double Value { get; set; }
}

static class DiagnosticFlowTerms
{
    public const string StageSinglePartCheck = "single_part_check";
    public const string StageFunctionalGroupCheck = "functional_group_check";
    public const string StageGroupInternalPartCheck = "group_internal_part_check";
    public const string KnowledgeScopePart = "part_knowledge";
    public const string KnowledgeScopeAssembly = "assembly_knowledge";
}

static class JsonOptions
{
    public static readonly JsonSerializerOptions Default = new()
    {
        PropertyNameCaseInsensitive = true,
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
    };
}

