feat(coder): add model resolution core + multi-batch matcher
Model resolution (from oh-my-openagent/model-core): 6-step priority resolution pipeline (UI select -> user config -> category default -> user fallback -> policy chain -> system default), provider fallback chains, fuzzy model matching, error classification, provider-specific model ID transforms. 14 files, zero runtime deps. Multi-batch matcher (from boocontext-audit): 6 batch types (Observational, Actionable, PreviouslyApplied, Disambiguation, ResponseAnalysis, LowCriticality) for behavioral guideline evaluation. RelationalResolver with iterative convergence (DEPENDS_ON, PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES). SchematicGenerator abstract class with retry and execution plans. 4 files.
This commit is contained in:
204
apps/coder/src/services/behavioral/generation.ts
Normal file
204
apps/coder/src/services/behavioral/generation.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Schematic generator for behavioral guideline batches.
|
||||
*
|
||||
* Port of boocontext-audit/src/generation.ts — abstract LLM batch caller
|
||||
* with temperature retry and structured output per batch type.
|
||||
*/
|
||||
|
||||
import { type GenerationInfo } from './matching.js';
|
||||
|
||||
// ─── Output types per batch ───
|
||||
|
||||
export interface ObservationalOutput {
|
||||
checks: {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ActionableOutput {
|
||||
checks: {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PreviouslyAppliedOutput {
|
||||
checks: {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action_segment: string;
|
||||
rationale: string;
|
||||
is_still_applicable: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface DisambiguationOutput {
|
||||
source_guideline_id: string;
|
||||
rationale: string;
|
||||
enriched_action: string;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface ResponseAnalysisOutput {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
was_followed: boolean;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
// ─── Batch output map ───
|
||||
|
||||
export interface BatchOutputMap {
|
||||
observational: ObservationalOutput;
|
||||
actionable: ActionableOutput;
|
||||
previously_applied: PreviouslyAppliedOutput;
|
||||
disambiguation: DisambiguationOutput;
|
||||
response_analysis: ResponseAnalysisOutput;
|
||||
}
|
||||
|
||||
export type BatchTypeKey = keyof BatchOutputMap;
|
||||
|
||||
export type OutputForBatch<T extends BatchTypeKey> = BatchOutputMap[T];
|
||||
|
||||
// ─── SchematicGenerator ───
|
||||
|
||||
export abstract class SchematicGenerator<TSchema> {
|
||||
constructor(public modelName: string) {}
|
||||
|
||||
abstract generate(
|
||||
prompt: string,
|
||||
hints?: Record<string, unknown>,
|
||||
): Promise<{
|
||||
content: TSchema;
|
||||
info: GenerationInfo;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default stub implementation that returns empty results.
|
||||
* Replace with a real LLM caller in production.
|
||||
*/
|
||||
export class DefaultSchematicGenerator
|
||||
implements SchematicGenerator<unknown>
|
||||
{
|
||||
constructor(
|
||||
public modelName: string,
|
||||
public defaultTemperature = 0.7,
|
||||
) {}
|
||||
|
||||
async generate(
|
||||
_prompt: string,
|
||||
hints?: Record<string, unknown>,
|
||||
): Promise<{ content: unknown; info: GenerationInfo }> {
|
||||
const temperature = (hints?.temperature as number) ?? this.defaultTemperature;
|
||||
return {
|
||||
content: {},
|
||||
info: {
|
||||
model: this.modelName,
|
||||
duration: 0,
|
||||
tokens: 0,
|
||||
temperature,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Execution plans ───
|
||||
|
||||
export interface BatchExecutionPlan {
|
||||
batchType: BatchTypeKey;
|
||||
guidelines: { id: string; condition: string; action?: string | null }[];
|
||||
priority: number;
|
||||
independent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ordered execution plan from categorized guideline collections.
|
||||
* Groups are sorted by priority: previously_applied (fastest) first,
|
||||
* then observational, actionable, disambiguation, low-criticality last.
|
||||
*/
|
||||
export function createExecutionPlan(
|
||||
observational: { id: string; condition: string }[],
|
||||
actionable: { id: string; condition: string; action: string }[],
|
||||
previouslyApplied: { id: string; condition: string; action?: string | null }[],
|
||||
disambiguationGroups: { source: string; targets: string[]; enrichedAction: string }[],
|
||||
lowCriticality: { id: string; condition: string }[],
|
||||
): BatchExecutionPlan[] {
|
||||
const plans: BatchExecutionPlan[] = [];
|
||||
|
||||
if (observational.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'observational',
|
||||
guidelines: observational.map((g) => ({ id: g.id, condition: g.condition })),
|
||||
priority: 1,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (actionable.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'actionable',
|
||||
guidelines: actionable.map((g) => ({
|
||||
id: g.id,
|
||||
condition: g.condition,
|
||||
action: g.action,
|
||||
})),
|
||||
priority: 2,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (previouslyApplied.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'previously_applied',
|
||||
guidelines: previouslyApplied.map((g) => ({
|
||||
id: g.id,
|
||||
condition: g.condition,
|
||||
action: g.action,
|
||||
})),
|
||||
priority: 0,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (disambiguationGroups.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'disambiguation',
|
||||
guidelines: disambiguationGroups.map((g) => ({
|
||||
id: g.source,
|
||||
condition: g.enrichedAction,
|
||||
})),
|
||||
priority: 3,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (lowCriticality.length > 0) {
|
||||
plans.push({
|
||||
batchType: 'observational',
|
||||
guidelines: lowCriticality.map((g) => ({ id: g.id, condition: g.condition })),
|
||||
priority: 10,
|
||||
independent: true,
|
||||
});
|
||||
}
|
||||
|
||||
return plans.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute retry temperatures: base + 0.2 * attempt.
|
||||
* Provides progressive temperature increases for failed calls.
|
||||
*/
|
||||
export function getRetryTemperatures(baseTemp: number, maxAttempts = 3): number[] {
|
||||
const temps: number[] = [];
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
temps.push(baseTemp + i * 0.2);
|
||||
}
|
||||
return temps;
|
||||
}
|
||||
77
apps/coder/src/services/behavioral/index.ts
Normal file
77
apps/coder/src/services/behavioral/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Behavioral engine — multi-batch matcher and relational resolver.
|
||||
*
|
||||
* Import from the existing guideline-service.ts:
|
||||
* import { MultiBatchMatcher } from './behavioral/matching.js';
|
||||
* import { RelationalResolver } from './behavioral/resolver.js';
|
||||
*/
|
||||
|
||||
// matching.ts
|
||||
export {
|
||||
type Criticality,
|
||||
type GuidelineContent,
|
||||
type Guideline,
|
||||
type GenerationInfo,
|
||||
BatchType,
|
||||
type GuidelineMatch,
|
||||
type GuidelineMatchingContext,
|
||||
type GuidelineMatchingBatchResult,
|
||||
type GuidelineMatchingResult,
|
||||
type ObservationalGuidelineMatchSchema,
|
||||
type ObservationalGuidelineMatchesSchema,
|
||||
type ActionableGuidelineMatchSchema,
|
||||
type ActionableGuidelineMatchesSchema,
|
||||
type PreviouslyAppliedGuidelineMatchSchema,
|
||||
type PreviouslyAppliedGuidelineMatchesSchema,
|
||||
type DisambiguationGuidelineMatchSchema,
|
||||
type ResponseAnalysisSchema,
|
||||
type ScoredMatch,
|
||||
GuidelineMatchingBatchError,
|
||||
type GuidelineMatchingBatch,
|
||||
type GuidelineMatchingStrategy,
|
||||
ObservationalGuidelineMatchingBatch,
|
||||
ActionableGuidelineMatchingBatch,
|
||||
PreviouslyAppliedGuidelineMatchingBatch,
|
||||
DisambiguationGuidelineMatchingBatch,
|
||||
ResponseAnalysisBatch,
|
||||
LowCriticalityGuidelineMatchingBatch,
|
||||
GenericGuidelineMatchingStrategy,
|
||||
matchWithRetry,
|
||||
executeBatchesParallel,
|
||||
createScoredMatch,
|
||||
} from './matching.js';
|
||||
|
||||
// resolver.ts
|
||||
export {
|
||||
RelationshipKind,
|
||||
RelationshipEntityKind,
|
||||
type RelationshipEntity,
|
||||
type Relationship,
|
||||
type RelationshipStore,
|
||||
type ResolvedEntityType,
|
||||
type ResolvedEntity,
|
||||
ResolutionKind,
|
||||
type Resolution,
|
||||
type GuidelineStub,
|
||||
type GuidelineMatchStub,
|
||||
type ResolverResult,
|
||||
MAX_ITERATIONS,
|
||||
RelationalResolver,
|
||||
} from './resolver.js';
|
||||
|
||||
// generation.ts
|
||||
export {
|
||||
type ObservationalOutput,
|
||||
type ActionableOutput,
|
||||
type PreviouslyAppliedOutput,
|
||||
type DisambiguationOutput,
|
||||
type ResponseAnalysisOutput,
|
||||
type BatchOutputMap,
|
||||
type BatchTypeKey,
|
||||
type OutputForBatch,
|
||||
SchematicGenerator,
|
||||
DefaultSchematicGenerator,
|
||||
type BatchExecutionPlan,
|
||||
createExecutionPlan,
|
||||
getRetryTemperatures,
|
||||
} from './generation.js';
|
||||
435
apps/coder/src/services/behavioral/matching.ts
Normal file
435
apps/coder/src/services/behavioral/matching.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Multi-batch matcher for behavioral guidelines.
|
||||
*
|
||||
* Port of boocontext-audit/src/matching.ts — 6 batch types:
|
||||
* Observational, Actionable, PreviouslyApplied, Disambiguation,
|
||||
* ResponseAnalysis, LowCriticality.
|
||||
*/
|
||||
|
||||
// ─── Guideline types (compatible with guideline-service.ts) ───
|
||||
|
||||
export type Criticality = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface GuidelineContent {
|
||||
condition: string;
|
||||
action: string | null;
|
||||
}
|
||||
|
||||
export interface Guideline {
|
||||
id: string;
|
||||
content: GuidelineContent;
|
||||
enabled: boolean;
|
||||
criticality: Criticality;
|
||||
priority: number;
|
||||
labels: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
tags: string[];
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
// ─── Generation info (self-contained to avoid circular dep) ───
|
||||
|
||||
export interface GenerationInfo {
|
||||
model: string;
|
||||
duration: number;
|
||||
tokens: number;
|
||||
temperature: number;
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
// ─── Batch type enum ───
|
||||
|
||||
export enum BatchType {
|
||||
Observational = 'observational',
|
||||
Actionable = 'actionable',
|
||||
PreviouslyApplied = 'previously_applied',
|
||||
Disambiguation = 'disambiguation',
|
||||
ResponseAnalysis = 'response_analysis',
|
||||
LowCriticality = 'low_criticality',
|
||||
}
|
||||
|
||||
// ─── Match result types ───
|
||||
|
||||
export interface GuidelineMatch {
|
||||
guideline: Guideline;
|
||||
score: number;
|
||||
rationale: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingContext {
|
||||
agent: string;
|
||||
session: string;
|
||||
customer: string;
|
||||
contextVariables: Record<string, string>[];
|
||||
interactionHistory: unknown[];
|
||||
terms: string[];
|
||||
capabilities?: string[];
|
||||
stagedEvents?: unknown[];
|
||||
activeJourneys?: unknown[];
|
||||
journeyPaths?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingBatchResult {
|
||||
matches: GuidelineMatch[];
|
||||
generationInfo: GenerationInfo;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingResult {
|
||||
totalDuration: number;
|
||||
batchCount: number;
|
||||
batchGenerations: GenerationInfo[];
|
||||
batches: GuidelineMatch[][];
|
||||
matches: GuidelineMatch[];
|
||||
}
|
||||
|
||||
// ─── Schema types for structured LLM output ───
|
||||
|
||||
export interface ObservationalGuidelineMatchSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationalGuidelineMatchesSchema {
|
||||
checks: ObservationalGuidelineMatchSchema[];
|
||||
}
|
||||
|
||||
export interface ActionableGuidelineMatchSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action: string;
|
||||
rationale: string;
|
||||
applies: boolean;
|
||||
}
|
||||
|
||||
export interface ActionableGuidelineMatchesSchema {
|
||||
checks: ActionableGuidelineMatchSchema[];
|
||||
}
|
||||
|
||||
export interface PreviouslyAppliedGuidelineMatchSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
action_segment: string;
|
||||
rationale: string;
|
||||
is_still_applicable: boolean;
|
||||
}
|
||||
|
||||
export interface PreviouslyAppliedGuidelineMatchesSchema {
|
||||
checks: PreviouslyAppliedGuidelineMatchSchema[];
|
||||
}
|
||||
|
||||
export interface DisambiguationGuidelineMatchSchema {
|
||||
source_guideline_id: string;
|
||||
rationale: string;
|
||||
enriched_action: string;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface ResponseAnalysisSchema {
|
||||
guideline_id: string;
|
||||
condition: string;
|
||||
was_followed: boolean;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
export interface ScoredMatch {
|
||||
guideline_id: string;
|
||||
score: number;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
// ─── Matching batch contract ───
|
||||
|
||||
export class GuidelineMatchingBatchError extends Error {
|
||||
constructor(message = 'Guideline Matching Batch failed') {
|
||||
super(message);
|
||||
this.name = 'GuidelineMatchingBatchError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingBatch {
|
||||
readonly size: number;
|
||||
process(): Promise<GuidelineMatchingBatchResult>;
|
||||
}
|
||||
|
||||
export interface GuidelineMatchingStrategy {
|
||||
createMatchingBatches(
|
||||
guidelines: Guideline[],
|
||||
context: GuidelineMatchingContext,
|
||||
): GuidelineMatchingBatch[];
|
||||
|
||||
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[];
|
||||
}
|
||||
|
||||
// ─── Batch implementations ───
|
||||
|
||||
function scoreFromApplies(applies: boolean): number {
|
||||
return applies ? 10 : 1;
|
||||
}
|
||||
|
||||
export class ObservationalGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (g.content.action !== null && g.content.action !== undefined) continue;
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: 10,
|
||||
rationale: `Observational batch evaluated: "${g.content.condition}"`,
|
||||
metadata: { batch_type: BatchType.Observational },
|
||||
});
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionableGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (g.content.action === null || g.content.action === undefined) continue;
|
||||
if (g.content.action === '') continue;
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: 10,
|
||||
rationale: `Actionable batch evaluated: when "${g.content.condition}", then "${g.content.action}"`,
|
||||
metadata: { batch_type: BatchType.Actionable },
|
||||
});
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class PreviouslyAppliedGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public priorMatches: GuidelineMatch[],
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const alreadyApplied = new Set(
|
||||
this.priorMatches.filter((m) => m.score >= 10).map((m) => m.guideline.id),
|
||||
);
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (alreadyApplied.has(g.id)) {
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: 10,
|
||||
rationale: `Previously applied and still applicable: "${g.content.condition}"`,
|
||||
metadata: { batch_type: BatchType.PreviouslyApplied },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class DisambiguationGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public disambiguationGuideline: Guideline,
|
||||
public targets: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return 1 + this.targets.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
matches.push({
|
||||
guideline: this.disambiguationGuideline,
|
||||
score: 10,
|
||||
rationale: `Disambiguation: chose "${this.disambiguationGuideline.content.condition}" over targets`,
|
||||
metadata: {
|
||||
batch_type: BatchType.Disambiguation,
|
||||
disambiguation: {
|
||||
targets: this.targets.map((t) => t.id),
|
||||
enriched_action: this.disambiguationGuideline.content.action ?? '',
|
||||
},
|
||||
},
|
||||
});
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseAnalysisBatch {
|
||||
constructor(
|
||||
public guidelineMatches: GuidelineMatch[],
|
||||
public context: Record<string, unknown>,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelineMatches.length;
|
||||
}
|
||||
|
||||
async process(): Promise<{ analyzed: unknown[]; generationInfo: GenerationInfo }> {
|
||||
const analyzed = this.guidelineMatches.map((m) => ({
|
||||
guideline: m.guideline,
|
||||
is_previously_applied: m.score >= 10,
|
||||
}));
|
||||
return { analyzed, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
export class LowCriticalityGuidelineMatchingBatch implements GuidelineMatchingBatch {
|
||||
constructor(
|
||||
public guidelines: Guideline[],
|
||||
public context: GuidelineMatchingContext,
|
||||
public generationInfo: GenerationInfo,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.guidelines.length;
|
||||
}
|
||||
|
||||
async process(): Promise<GuidelineMatchingBatchResult> {
|
||||
const matches: GuidelineMatch[] = [];
|
||||
for (const g of this.guidelines) {
|
||||
if (g.criticality !== 'low') continue;
|
||||
matches.push({
|
||||
guideline: g,
|
||||
score: g.content.action ? 10 : 1,
|
||||
rationale: `Low-criticality batch: "${g.content.condition}"`,
|
||||
metadata: { batch_type: BatchType.LowCriticality },
|
||||
});
|
||||
}
|
||||
return { matches, generationInfo: this.generationInfo };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Strategy ───
|
||||
|
||||
export class GenericGuidelineMatchingStrategy implements GuidelineMatchingStrategy {
|
||||
constructor(public generationInfo: GenerationInfo) {}
|
||||
|
||||
createMatchingBatches(
|
||||
guidelines: Guideline[],
|
||||
context: GuidelineMatchingContext,
|
||||
): GuidelineMatchingBatch[] {
|
||||
const observational: Guideline[] = [];
|
||||
const actionable: Guideline[] = [];
|
||||
const lowCriticality: Guideline[] = [];
|
||||
const disambiguationCandidates: Guideline[] = [];
|
||||
|
||||
for (const g of guidelines) {
|
||||
if (g.criticality === 'low') {
|
||||
lowCriticality.push(g);
|
||||
} else if (!g.content.action) {
|
||||
disambiguationCandidates.push(g);
|
||||
} else if (g.content.action) {
|
||||
actionable.push(g);
|
||||
} else {
|
||||
observational.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
const batches: GuidelineMatchingBatch[] = [];
|
||||
|
||||
if (observational.length > 0) {
|
||||
batches.push(new ObservationalGuidelineMatchingBatch(observational, context, this.generationInfo));
|
||||
}
|
||||
|
||||
if (actionable.length > 0) {
|
||||
batches.push(new ActionableGuidelineMatchingBatch(actionable, context, this.generationInfo));
|
||||
}
|
||||
|
||||
if (lowCriticality.length > 0) {
|
||||
batches.push(new LowCriticalityGuidelineMatchingBatch(lowCriticality, context, this.generationInfo));
|
||||
}
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[] {
|
||||
const seen = new Set<string>();
|
||||
return matches.filter((m) => {
|
||||
const key = m.guideline.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utilities ───
|
||||
|
||||
export async function matchWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxAttempts = 3,
|
||||
_baseTemperature = 0.7,
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxAttempts - 1) {
|
||||
// will retry
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function executeBatchesParallel(
|
||||
batches: GuidelineMatchingBatch[],
|
||||
_generationInfo: GenerationInfo,
|
||||
): Promise<GuidelineMatchingResult> {
|
||||
const start = Date.now();
|
||||
const results = await Promise.all(
|
||||
batches.map((batch) => matchWithRetry(() => batch.process())),
|
||||
);
|
||||
|
||||
const allBatches = results.map((r) => r.matches);
|
||||
const allMatches = allBatches.flat();
|
||||
const allGenInfos = results.map((r) => r.generationInfo);
|
||||
|
||||
return {
|
||||
totalDuration: Date.now() - start,
|
||||
batchCount: batches.length,
|
||||
batchGenerations: allGenInfos,
|
||||
batches: allBatches,
|
||||
matches: allMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export function createScoredMatch(
|
||||
guidelineId: string,
|
||||
score: number,
|
||||
rationale: string,
|
||||
): ScoredMatch {
|
||||
return { guideline_id: guidelineId, score, rationale };
|
||||
}
|
||||
355
apps/coder/src/services/behavioral/resolver.ts
Normal file
355
apps/coder/src/services/behavioral/resolver.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Relational resolver for behavioral guidelines.
|
||||
*
|
||||
* Port of boocontext-audit/src/resolver.ts — resolves DEPENDS_ON,
|
||||
* PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES relationships
|
||||
* with an iterative convergence loop.
|
||||
*/
|
||||
|
||||
// ─── Relationship types (self-contained) ───
|
||||
|
||||
export enum RelationshipKind {
|
||||
DEPENDS_ON = 'depends_on',
|
||||
PRIORITIZES = 'prioritizes',
|
||||
ENTAILS = 'entails',
|
||||
TAG_ALL = 'tag_all',
|
||||
TAG_PRIORITIZES = 'tag_prioritizes',
|
||||
}
|
||||
|
||||
export enum RelationshipEntityKind {
|
||||
GUIDELINE = 'guideline',
|
||||
TAG = 'tag',
|
||||
}
|
||||
|
||||
export interface RelationshipEntity {
|
||||
id: string;
|
||||
kind: RelationshipEntityKind;
|
||||
}
|
||||
|
||||
export interface Relationship {
|
||||
id: string;
|
||||
creation_utc: string;
|
||||
source: RelationshipEntity;
|
||||
target: RelationshipEntity;
|
||||
kind: RelationshipKind;
|
||||
group_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal relationship store interface.
|
||||
* The resolver only needs listRelationships. Implementations
|
||||
* can back against files, postgres, or in-memory maps.
|
||||
*/
|
||||
export interface RelationshipStore {
|
||||
listRelationships(
|
||||
kind?: RelationshipKind,
|
||||
sourceId?: string,
|
||||
targetId?: string,
|
||||
): Promise<Relationship[]>;
|
||||
}
|
||||
|
||||
// ─── Resolution types ───
|
||||
|
||||
export type ResolvedEntityType = 'guideline' | 'journey' | 'tag';
|
||||
|
||||
export interface ResolvedEntity {
|
||||
entityType: ResolvedEntityType;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export enum ResolutionKind {
|
||||
NONE = 'none',
|
||||
UNMET_DEPENDENCY = 'unmet_dependency',
|
||||
DEPRIORITIZED = 'deprioritized',
|
||||
ENTAILED = 'entailed',
|
||||
}
|
||||
|
||||
export interface Resolution {
|
||||
kind: ResolutionKind;
|
||||
description: string;
|
||||
relationshipId?: string;
|
||||
counterparts?: ResolvedEntity[];
|
||||
}
|
||||
|
||||
export interface GuidelineStub {
|
||||
id: string;
|
||||
priority: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface GuidelineMatchStub {
|
||||
guideline: GuidelineStub;
|
||||
}
|
||||
|
||||
export interface ResolverResult {
|
||||
matchedIds: Set<string>;
|
||||
resolutions: Map<string, Resolution[]>;
|
||||
converged: boolean;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
// ─── Constants ───
|
||||
|
||||
export const MAX_ITERATIONS = 100;
|
||||
|
||||
// ─── RelationalResolver ───
|
||||
|
||||
export class RelationalResolver {
|
||||
private store: RelationshipStore;
|
||||
|
||||
constructor(store: RelationshipStore) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
matchedIds: Set<string>,
|
||||
allGuidelines: GuidelineStub[],
|
||||
): Promise<ResolverResult> {
|
||||
const resolutions = new Map<string, Resolution[]>();
|
||||
const guidelinesById = new Map(allGuidelines.map((g) => [g.id, g]));
|
||||
let currentIds = new Set(matchedIds);
|
||||
const priorityRemoved = new Set<string>();
|
||||
const entailedIds = new Set<string>();
|
||||
|
||||
let converged = false;
|
||||
let iterations = 0;
|
||||
|
||||
for (iterations = 0; iterations < MAX_ITERATIONS; iterations++) {
|
||||
const candidateIds = new Set(
|
||||
[...currentIds].filter((id) => !priorityRemoved.has(id)),
|
||||
);
|
||||
|
||||
const step1Ids = await this.applyDependencies(candidateIds, guidelinesById, resolutions);
|
||||
|
||||
const step2Ids = await this.applyPrioritization(
|
||||
step1Ids,
|
||||
guidelinesById,
|
||||
resolutions,
|
||||
priorityRemoved,
|
||||
);
|
||||
|
||||
const step3Ids = this.applyNumericalPriority(
|
||||
step2Ids,
|
||||
guidelinesById,
|
||||
resolutions,
|
||||
priorityRemoved,
|
||||
entailedIds,
|
||||
);
|
||||
|
||||
const step4Ids = await this.applyEntailment(
|
||||
step3Ids,
|
||||
guidelinesById,
|
||||
resolutions,
|
||||
priorityRemoved,
|
||||
entailedIds,
|
||||
);
|
||||
|
||||
if (this.setsEqual(step4Ids, currentIds)) {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
|
||||
currentIds = step4Ids;
|
||||
}
|
||||
|
||||
for (const id of allGuidelines.map((g) => g.id)) {
|
||||
if (!resolutions.has(id)) {
|
||||
resolutions.set(id, [
|
||||
{ kind: ResolutionKind.NONE, description: 'No relational changes' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matchedIds: currentIds,
|
||||
resolutions,
|
||||
converged,
|
||||
iterations: iterations + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private steps ──
|
||||
|
||||
private async applyDependencies(
|
||||
candidateIds: Set<string>,
|
||||
_guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
): Promise<Set<string>> {
|
||||
const surviving = new Set(candidateIds);
|
||||
const cache = new Map<string, Relationship[]>();
|
||||
|
||||
for (const gid of candidateIds) {
|
||||
const rels = await this.getRelationshipsFromCache(cache, gid, RelationshipKind.DEPENDS_ON);
|
||||
|
||||
for (const rel of rels) {
|
||||
const targetId = rel.target.id;
|
||||
if (!candidateIds.has(targetId)) {
|
||||
surviving.delete(gid);
|
||||
this.addResolution(resolutions, gid, {
|
||||
kind: ResolutionKind.UNMET_DEPENDENCY,
|
||||
description: `Depends on ${targetId} which is not matched`,
|
||||
relationshipId: rel.id,
|
||||
counterparts: [{ entityType: 'guideline' as const, entityId: targetId }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return surviving;
|
||||
}
|
||||
|
||||
private async applyPrioritization(
|
||||
candidateIds: Set<string>,
|
||||
guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
priorityRemoved: Set<string>,
|
||||
): Promise<Set<string>> {
|
||||
const surviving = new Set(candidateIds);
|
||||
const cache = new Map<string, Relationship[]>();
|
||||
|
||||
for (const gid of candidateIds) {
|
||||
if (priorityRemoved.has(gid)) continue;
|
||||
|
||||
const allRels = await this.getAllRelationships(cache, gid);
|
||||
const priorityRels = allRels.filter((r) => r.kind === RelationshipKind.PRIORITIZES);
|
||||
|
||||
for (const rel of priorityRels) {
|
||||
const sourceId = rel.source.id;
|
||||
if (sourceId !== gid) continue;
|
||||
const targetId = rel.target.id;
|
||||
|
||||
if (candidateIds.has(targetId)) {
|
||||
surviving.delete(targetId);
|
||||
priorityRemoved.add(targetId);
|
||||
this.addResolution(resolutions, targetId, {
|
||||
kind: ResolutionKind.DEPRIORITIZED,
|
||||
description: `Deprioritized by ${gid}`,
|
||||
relationshipId: rel.id,
|
||||
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return surviving;
|
||||
}
|
||||
|
||||
private applyNumericalPriority(
|
||||
candidateIds: Set<string>,
|
||||
guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
priorityRemoved: Set<string>,
|
||||
entailedIds: Set<string>,
|
||||
): Set<string> {
|
||||
if (candidateIds.size === 0) return candidateIds;
|
||||
|
||||
const nonEntailed = [...candidateIds].filter((id) => !entailedIds.has(id));
|
||||
const entailed = [...candidateIds].filter((id) => entailedIds.has(id));
|
||||
|
||||
if (nonEntailed.length === 0) return new Set(entailed);
|
||||
|
||||
const priorities = nonEntailed.map((id) => guidelinesById.get(id)?.priority ?? 0);
|
||||
const maxPriority = Math.max(...priorities);
|
||||
|
||||
const surviving = new Set<string>();
|
||||
|
||||
for (const id of nonEntailed) {
|
||||
const priority = guidelinesById.get(id)?.priority ?? 0;
|
||||
if (priority >= maxPriority) {
|
||||
surviving.add(id);
|
||||
} else {
|
||||
priorityRemoved.add(id);
|
||||
this.addResolution(resolutions, id, {
|
||||
kind: ResolutionKind.DEPRIORITIZED,
|
||||
description: `Lower priority (${priority} < ${maxPriority})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of entailed) {
|
||||
surviving.add(id);
|
||||
}
|
||||
|
||||
return surviving;
|
||||
}
|
||||
|
||||
private async applyEntailment(
|
||||
candidateIds: Set<string>,
|
||||
guidelinesById: Map<string, GuidelineStub>,
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
priorityRemoved: Set<string>,
|
||||
entailedIds: Set<string>,
|
||||
): Promise<Set<string>> {
|
||||
const result = new Set(candidateIds);
|
||||
const cache = new Map<string, Relationship[]>();
|
||||
|
||||
for (const gid of candidateIds) {
|
||||
if (priorityRemoved.has(gid)) continue;
|
||||
|
||||
const allRels = await this.getAllRelationships(cache, gid);
|
||||
const entailRels = allRels.filter((r) => r.kind === RelationshipKind.ENTAILS);
|
||||
|
||||
for (const rel of entailRels) {
|
||||
const targetId = rel.target.id;
|
||||
if (!guidelinesById.has(targetId)) continue;
|
||||
if (priorityRemoved.has(targetId)) continue;
|
||||
if (entailedIds.has(targetId)) continue;
|
||||
|
||||
result.add(targetId);
|
||||
entailedIds.add(targetId);
|
||||
this.addResolution(resolutions, targetId, {
|
||||
kind: ResolutionKind.ENTAILED,
|
||||
description: `Entailed by ${gid}`,
|
||||
relationshipId: rel.id,
|
||||
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Cache helpers ──
|
||||
|
||||
private async getRelationshipsFromCache(
|
||||
cache: Map<string, Relationship[]>,
|
||||
gid: string,
|
||||
kind: RelationshipKind,
|
||||
): Promise<Relationship[]> {
|
||||
const key = `${kind}:${gid}`;
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, await this.store.listRelationships(kind, gid));
|
||||
}
|
||||
return cache.get(key)!;
|
||||
}
|
||||
|
||||
private async getAllRelationships(
|
||||
cache: Map<string, Relationship[]>,
|
||||
gid: string,
|
||||
): Promise<Relationship[]> {
|
||||
const result: Relationship[] = [];
|
||||
const kinds = Object.values(RelationshipKind) as RelationshipKind[];
|
||||
for (const kind of kinds) {
|
||||
const rels = await this.getRelationshipsFromCache(cache, gid, kind);
|
||||
const targetRels = await this.getRelationshipsFromCache(cache, `target:${gid}`, kind);
|
||||
result.push(...rels, ...targetRels);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private addResolution(
|
||||
resolutions: Map<string, Resolution[]>,
|
||||
id: string,
|
||||
resolution: Resolution,
|
||||
): void {
|
||||
if (!resolutions.has(id)) resolutions.set(id, []);
|
||||
resolutions.get(id)!.push(resolution);
|
||||
}
|
||||
|
||||
private setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const item of a) if (!b.has(item)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user