0a84c747f2
Three follow-ups to the 671-norm cross-reference matrix: 1. Tech-file renderer (Go): standards_applied section now gets a deterministic Markdown appendix with the DIN/ANSI/GB/JIS mappings for the project's suggested norms. Built from registry, never hallucinated by LLM. Applied both to LLM and fallback content paths. 2. Frontend NormCrossRefPanel (Next.js): expandable row in the IACE library norms tab now has a "Internationale Aequivalenzen anzeigen" button that lazy-loads /iace/norms-library/:id/crossref and renders a colour-coded table (relation + confidence). Region labels humanised (US — ANSI, China (GB), Japan (JIS), etc.). 3. Contract tests (Go): 4 new handler tests pinning the response shape of GetNormCrossRef and ListNormCrossRefs. Equivalent to an OpenAPI snapshot for these specific endpoints — ai-compliance-sdk has no full OpenAPI baseline yet (separate ticket). Tests: 6 renderer tests + 4 handler contract tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
6.9 KiB
Go
214 lines
6.9 KiB
Go
package iace
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
|
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ============================================================================
|
|
// TechFileGenerator — LLM-based generation of technical file sections
|
|
// ============================================================================
|
|
|
|
// TechFileGenerator generates technical file section content using LLM and RAG.
|
|
type TechFileGenerator struct {
|
|
llmRegistry *llm.ProviderRegistry
|
|
ragClient *ucca.LegalRAGClient
|
|
store *Store
|
|
}
|
|
|
|
// NewTechFileGenerator creates a new TechFileGenerator.
|
|
func NewTechFileGenerator(registry *llm.ProviderRegistry, ragClient *ucca.LegalRAGClient, store *Store) *TechFileGenerator {
|
|
return &TechFileGenerator{
|
|
llmRegistry: registry,
|
|
ragClient: ragClient,
|
|
store: store,
|
|
}
|
|
}
|
|
|
|
// SectionGenerationContext holds all project data needed for LLM section generation.
|
|
type SectionGenerationContext struct {
|
|
Project *Project
|
|
Components []Component
|
|
Hazards []Hazard
|
|
Assessments map[uuid.UUID][]RiskAssessment // keyed by hazardID
|
|
Mitigations map[uuid.UUID][]Mitigation // keyed by hazardID
|
|
Classifications []RegulatoryClassification
|
|
Evidence []Evidence
|
|
RAGContext string // aggregated text from RAG search
|
|
}
|
|
|
|
// ============================================================================
|
|
// BuildSectionContext — loads all project data + RAG context
|
|
// ============================================================================
|
|
|
|
// BuildSectionContext loads project data and RAG context for a given section type.
|
|
func (g *TechFileGenerator) BuildSectionContext(ctx context.Context, projectID uuid.UUID, sectionType string) (*SectionGenerationContext, error) {
|
|
// Load project
|
|
project, err := g.store.GetProject(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load project: %w", err)
|
|
}
|
|
if project == nil {
|
|
return nil, fmt.Errorf("project %s not found", projectID)
|
|
}
|
|
|
|
// Load components
|
|
components, err := g.store.ListComponents(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load components: %w", err)
|
|
}
|
|
|
|
// Load hazards
|
|
hazards, err := g.store.ListHazards(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load hazards: %w", err)
|
|
}
|
|
|
|
// Load assessments and mitigations per hazard
|
|
assessments := make(map[uuid.UUID][]RiskAssessment)
|
|
mitigations := make(map[uuid.UUID][]Mitigation)
|
|
for _, h := range hazards {
|
|
a, err := g.store.ListAssessments(ctx, h.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load assessments for hazard %s: %w", h.ID, err)
|
|
}
|
|
assessments[h.ID] = a
|
|
|
|
m, err := g.store.ListMitigations(ctx, h.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load mitigations for hazard %s: %w", h.ID, err)
|
|
}
|
|
mitigations[h.ID] = m
|
|
}
|
|
|
|
// Load classifications
|
|
classifications, err := g.store.GetClassifications(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load classifications: %w", err)
|
|
}
|
|
|
|
// Load evidence
|
|
evidence, err := g.store.ListEvidence(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load evidence: %w", err)
|
|
}
|
|
|
|
// Perform RAG search for section-specific context
|
|
ragContext := ""
|
|
if g.ragClient != nil {
|
|
ragQuery := buildRAGQuery(sectionType)
|
|
results, ragErr := g.ragClient.SearchCollection(ctx, "bp_iace_libraries", ragQuery, nil, 5)
|
|
if ragErr == nil && len(results) > 0 {
|
|
var ragParts []string
|
|
for _, r := range results {
|
|
entry := fmt.Sprintf("[%s] %s", r.RegulationShort, truncateForPrompt(r.Text, 400))
|
|
ragParts = append(ragParts, entry)
|
|
}
|
|
ragContext = strings.Join(ragParts, "\n\n")
|
|
}
|
|
// RAG failure is non-fatal — we proceed without context
|
|
}
|
|
|
|
return &SectionGenerationContext{
|
|
Project: project,
|
|
Components: components,
|
|
Hazards: hazards,
|
|
Assessments: assessments,
|
|
Mitigations: mitigations,
|
|
Classifications: classifications,
|
|
Evidence: evidence,
|
|
RAGContext: ragContext,
|
|
}, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// GenerateSection — main entry point
|
|
// ============================================================================
|
|
|
|
// GenerateSection generates the content for a technical file section using LLM.
|
|
// If LLM is unavailable, returns an enhanced placeholder with project data.
|
|
func (g *TechFileGenerator) GenerateSection(ctx context.Context, projectID uuid.UUID, sectionType string) (string, error) {
|
|
sctx, err := g.BuildSectionContext(ctx, projectID, sectionType)
|
|
if err != nil {
|
|
return "", fmt.Errorf("build section context: %w", err)
|
|
}
|
|
|
|
// Build prompts
|
|
systemPrompt := getSystemPrompt(sectionType)
|
|
userPrompt := buildUserPrompt(sctx, sectionType)
|
|
|
|
// Attempt LLM generation
|
|
resp, err := g.llmRegistry.Chat(ctx, &llm.ChatRequest{
|
|
Messages: []llm.Message{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: userPrompt},
|
|
},
|
|
Temperature: 0.15,
|
|
MaxTokens: 4096,
|
|
})
|
|
if err != nil {
|
|
// LLM unavailable — return structured fallback with real project data
|
|
return appendCrossRefIfApplicable(buildFallbackContent(sctx, sectionType), sctx, sectionType), nil
|
|
}
|
|
|
|
return appendCrossRefIfApplicable(resp.Message.Content, sctx, sectionType), nil
|
|
}
|
|
|
|
// appendCrossRefIfApplicable adds the international cross-reference appendix
|
|
// (DIN/ANSI/GB/JIS) to the "standards_applied" section. For other section
|
|
// types it returns content unchanged. The appendix is built deterministically
|
|
// from the in-process registry, so it is never hallucinated by the LLM.
|
|
func appendCrossRefIfApplicable(content string, sctx *SectionGenerationContext, sectionType string) string {
|
|
if sectionType != SectionStandardsApplied {
|
|
return content
|
|
}
|
|
normIDs := suggestNormIDsForProject(sctx)
|
|
appendix := RenderCrossRefAppendix(normIDs)
|
|
if appendix == "" {
|
|
return content
|
|
}
|
|
return content + appendix
|
|
}
|
|
|
|
// suggestNormIDsForProject reuses the existing SuggestNorms heuristic to pick
|
|
// the norms most likely applicable to this project. We only need the IDs;
|
|
// the rest of the SuggestNorms output (scores, reasons) is discarded.
|
|
func suggestNormIDsForProject(sctx *SectionGenerationContext) []string {
|
|
if sctx == nil || sctx.Project == nil {
|
|
return nil
|
|
}
|
|
hazardCats := make([]string, 0, len(sctx.Hazards))
|
|
seenCat := map[string]bool{}
|
|
for _, h := range sctx.Hazards {
|
|
if h.Category != "" && !seenCat[h.Category] {
|
|
seenCat[h.Category] = true
|
|
hazardCats = append(hazardCats, h.Category)
|
|
}
|
|
}
|
|
result := SuggestNorms(sctx.Project.MachineType, hazardCats, nil)
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, result.Total)
|
|
seenID := map[string]bool{}
|
|
push := func(suggs []NormSuggestion) {
|
|
for _, s := range suggs {
|
|
if s.Norm.ID == "" || seenID[s.Norm.ID] {
|
|
continue
|
|
}
|
|
seenID[s.Norm.ID] = true
|
|
ids = append(ids, s.Norm.ID)
|
|
}
|
|
}
|
|
push(result.ANorms)
|
|
push(result.B1Norms)
|
|
push(result.B2Norms)
|
|
push(result.CNorms)
|
|
return ids
|
|
}
|