afb3f83f30
Engine precision (stop foreign-machine patterns leaking into a project):
- Wire project.MachineType into the engine machine-type gate (empty input no
longer fires every machine class — press/cnc/excavator/crane/medical...).
- Capability-domain gating extended by 7 domains (outdoor, ventilation,
machining, bulk, palletizer, playground, fitness) so domain-specific hazards
only fire when the narrative names that domain; emitted via keyword_dictionary.
- Relevance backstop moved into iace (single gating contract, testable), and its
dominant false-anchor class removed (a long pattern word no longer matches a
short common token; prepositions/leitung added to the generic stoplist).
- New guard tests: TestCrossDomainPrecision (full pipeline, 0 foreign per GT) and
TestPatternReachability now asserts 0 dead patterns. Both GTs keep coverage 1.0.
Reachability fix: the 51 dead patterns required electrical/pneumatic/hydraulic
tags nothing produced — renamed to the canonical electrical_energy/
pneumatic_pressure/hydraulic_pressure/hydraulic_part.
Component review (negation is best-effort + expert-correctable):
- Parser surfaces negated components (ComponentMatch.Negated) instead of dropping
them; negated contribute no tags/energy → no phantom hazards.
- presence_status (vorhanden|nicht_vorhanden|geloescht) + ce_marked on components;
only `vorhanden` feed matching. CE+safety-relevant flags the PL/SIL obligation.
- Force re-seed preserves the expert's component decisions instead of wiping them.
- Tag-based component→hazard assignment (was: all on the first component).
- Negation-aware narrative parsing ("keine Pneumatik" no longer extracts it).
Local-dev DB: ai-sdk sets search_path=compliance,core,public; reconcile migrations
152-156 bring the consolidated local iace tables to the current schema + add the
presence_status/ce_marked columns. Machine-type vocabulary endpoint for the form.
[migration-approved]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
324 lines
9.5 KiB
Go
324 lines
9.5 KiB
Go
package iace
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
|
|
// ============================================================================
|
|
// Component CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateComponent creates a new component within a project
|
|
func (s *Store) CreateComponent(ctx context.Context, req CreateComponentRequest) (*Component, error) {
|
|
status := req.PresenceStatus
|
|
if status == "" {
|
|
status = PresencePresent
|
|
}
|
|
comp := &Component{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
ParentID: req.ParentID,
|
|
Name: req.Name,
|
|
ComponentType: req.ComponentType,
|
|
Version: req.Version,
|
|
Description: req.Description,
|
|
IsSafetyRelevant: req.IsSafetyRelevant,
|
|
IsNetworked: req.IsNetworked,
|
|
CEMarked: req.CEMarked,
|
|
PresenceStatus: status,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_components (
|
|
id, project_id, parent_id, name, component_type,
|
|
version, description, is_safety_relevant, is_networked,
|
|
ce_marked, presence_status, metadata, sort_order, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, $9,
|
|
$10, $11, $12, $13, $14, $15
|
|
)
|
|
`,
|
|
comp.ID, comp.ProjectID, comp.ParentID, comp.Name, string(comp.ComponentType),
|
|
comp.Version, comp.Description, comp.IsSafetyRelevant, comp.IsNetworked,
|
|
comp.CEMarked, comp.PresenceStatus, comp.Metadata, comp.SortOrder, comp.CreatedAt, comp.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create component: %w", err)
|
|
}
|
|
|
|
return comp, nil
|
|
}
|
|
|
|
// GetComponent retrieves a component by ID
|
|
func (s *Store) GetComponent(ctx context.Context, id uuid.UUID) (*Component, error) {
|
|
var c Component
|
|
var compType string
|
|
var metadata []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, parent_id, name, component_type,
|
|
version, description, is_safety_relevant, is_networked,
|
|
ce_marked, presence_status, metadata, sort_order, created_at, updated_at
|
|
FROM iace_components WHERE id = $1
|
|
`, id).Scan(
|
|
&c.ID, &c.ProjectID, &c.ParentID, &c.Name, &compType,
|
|
&c.Version, &c.Description, &c.IsSafetyRelevant, &c.IsNetworked,
|
|
&c.CEMarked, &c.PresenceStatus, &metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get component: %w", err)
|
|
}
|
|
|
|
c.ComponentType = ComponentType(compType)
|
|
json.Unmarshal(metadata, &c.Metadata)
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
// ListComponents lists all components for a project
|
|
func (s *Store) ListComponents(ctx context.Context, projectID uuid.UUID) ([]Component, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, parent_id, name, component_type,
|
|
version, description, is_safety_relevant, is_networked,
|
|
ce_marked, presence_status, metadata, sort_order, created_at, updated_at
|
|
FROM iace_components WHERE project_id = $1
|
|
ORDER BY sort_order ASC, created_at ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list components: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var components []Component
|
|
for rows.Next() {
|
|
var c Component
|
|
var compType string
|
|
var metadata []byte
|
|
|
|
err := rows.Scan(
|
|
&c.ID, &c.ProjectID, &c.ParentID, &c.Name, &compType,
|
|
&c.Version, &c.Description, &c.IsSafetyRelevant, &c.IsNetworked,
|
|
&c.CEMarked, &c.PresenceStatus, &metadata, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list components scan: %w", err)
|
|
}
|
|
|
|
c.ComponentType = ComponentType(compType)
|
|
json.Unmarshal(metadata, &c.Metadata)
|
|
|
|
components = append(components, c)
|
|
}
|
|
|
|
return components, nil
|
|
}
|
|
|
|
// UpdateComponent updates a component with a dynamic set of fields
|
|
func (s *Store) UpdateComponent(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*Component, error) {
|
|
if len(updates) == 0 {
|
|
return s.GetComponent(ctx, id)
|
|
}
|
|
|
|
query := "UPDATE iace_components SET updated_at = NOW()"
|
|
args := []interface{}{id}
|
|
argIdx := 2
|
|
|
|
for key, val := range updates {
|
|
switch key {
|
|
case "name", "version", "description":
|
|
query += fmt.Sprintf(", %s = $%d", key, argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "component_type":
|
|
query += fmt.Sprintf(", component_type = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "is_safety_relevant":
|
|
query += fmt.Sprintf(", is_safety_relevant = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "is_networked":
|
|
query += fmt.Sprintf(", is_networked = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "presence_status":
|
|
query += fmt.Sprintf(", presence_status = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "ce_marked":
|
|
query += fmt.Sprintf(", ce_marked = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "sort_order":
|
|
query += fmt.Sprintf(", sort_order = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
case "metadata":
|
|
metaJSON, _ := json.Marshal(val)
|
|
query += fmt.Sprintf(", metadata = $%d", argIdx)
|
|
args = append(args, metaJSON)
|
|
argIdx++
|
|
case "parent_id":
|
|
query += fmt.Sprintf(", parent_id = $%d", argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " WHERE id = $1"
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update component: %w", err)
|
|
}
|
|
|
|
return s.GetComponent(ctx, id)
|
|
}
|
|
|
|
// DeleteComponent deletes a component by ID
|
|
func (s *Store) DeleteComponent(ctx context.Context, id uuid.UUID) error {
|
|
_, err := s.pool.Exec(ctx, "DELETE FROM iace_components WHERE id = $1", id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete component: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Classification Operations
|
|
// ============================================================================
|
|
|
|
// UpsertClassification inserts or updates a regulatory classification for a project
|
|
func (s *Store) UpsertClassification(ctx context.Context, projectID uuid.UUID, regulation RegulationType, result string, riskLevel string, confidence float64, reasoning string, ragSources, requirements json.RawMessage) (*RegulatoryClassification, error) {
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_classifications (
|
|
id, project_id, regulation, classification_result,
|
|
risk_level, confidence, reasoning,
|
|
rag_sources, requirements,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7,
|
|
$8, $9,
|
|
$10, $11
|
|
)
|
|
ON CONFLICT (project_id, regulation)
|
|
DO UPDATE SET
|
|
classification_result = EXCLUDED.classification_result,
|
|
risk_level = EXCLUDED.risk_level,
|
|
confidence = EXCLUDED.confidence,
|
|
reasoning = EXCLUDED.reasoning,
|
|
rag_sources = EXCLUDED.rag_sources,
|
|
requirements = EXCLUDED.requirements,
|
|
updated_at = EXCLUDED.updated_at
|
|
`,
|
|
id, projectID, string(regulation), result,
|
|
riskLevel, confidence, reasoning,
|
|
ragSources, requirements,
|
|
now, now,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upsert classification: %w", err)
|
|
}
|
|
|
|
// Retrieve the upserted row (may have kept the original ID on conflict)
|
|
return s.getClassificationByProjectAndRegulation(ctx, projectID, regulation)
|
|
}
|
|
|
|
// getClassificationByProjectAndRegulation is a helper to fetch a single classification
|
|
func (s *Store) getClassificationByProjectAndRegulation(ctx context.Context, projectID uuid.UUID, regulation RegulationType) (*RegulatoryClassification, error) {
|
|
var c RegulatoryClassification
|
|
var reg, rl string
|
|
var ragSources, requirements []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, regulation, classification_result,
|
|
risk_level, confidence, reasoning,
|
|
rag_sources, requirements,
|
|
created_at, updated_at
|
|
FROM iace_classifications
|
|
WHERE project_id = $1 AND regulation = $2
|
|
`, projectID, string(regulation)).Scan(
|
|
&c.ID, &c.ProjectID, ®, &c.ClassificationResult,
|
|
&rl, &c.Confidence, &c.Reasoning,
|
|
&ragSources, &requirements,
|
|
&c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get classification: %w", err)
|
|
}
|
|
|
|
c.Regulation = RegulationType(reg)
|
|
c.RiskLevel = RiskLevel(rl)
|
|
json.Unmarshal(ragSources, &c.RAGSources)
|
|
json.Unmarshal(requirements, &c.Requirements)
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
// GetClassifications retrieves all classifications for a project
|
|
func (s *Store) GetClassifications(ctx context.Context, projectID uuid.UUID) ([]RegulatoryClassification, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, regulation, classification_result,
|
|
risk_level, confidence, reasoning,
|
|
rag_sources, requirements,
|
|
created_at, updated_at
|
|
FROM iace_classifications
|
|
WHERE project_id = $1
|
|
ORDER BY regulation ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get classifications: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var classifications []RegulatoryClassification
|
|
for rows.Next() {
|
|
var c RegulatoryClassification
|
|
var reg, rl string
|
|
var ragSources, requirements []byte
|
|
|
|
err := rows.Scan(
|
|
&c.ID, &c.ProjectID, ®, &c.ClassificationResult,
|
|
&rl, &c.Confidence, &c.Reasoning,
|
|
&ragSources, &requirements,
|
|
&c.CreatedAt, &c.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get classifications scan: %w", err)
|
|
}
|
|
|
|
c.Regulation = RegulationType(reg)
|
|
c.RiskLevel = RiskLevel(rl)
|
|
json.Unmarshal(ragSources, &c.RAGSources)
|
|
json.Unmarshal(requirements, &c.Requirements)
|
|
|
|
classifications = append(classifications, c)
|
|
}
|
|
|
|
return classifications, nil
|
|
}
|