agent build
This commit is contained in:
@@ -26,11 +26,55 @@ var allowedDefinitionFilters = map[string]bool{
|
||||
// DefinitionsService manages authored Agent and Skill definitions.
|
||||
type DefinitionsService struct {
|
||||
repo *repo.DefinitionsRepo
|
||||
|
||||
// versions is the append-only history beside the editable definition.
|
||||
// Written on publish, never on save — see snapshotIfPublished.
|
||||
versions *repo.VersionsRepo
|
||||
|
||||
// unknownTools reports which of a spec's tool names are not registered.
|
||||
// nil means no check — the shipped importer path, which has its own.
|
||||
unknownTools func([]string) []string
|
||||
}
|
||||
|
||||
// NewDefinitions builds a definitions service over a repository.
|
||||
func NewDefinitions(db repo.Querier) *DefinitionsService {
|
||||
return &DefinitionsService{repo: repo.NewDefinitionsRepo(db)}
|
||||
return &DefinitionsService{
|
||||
repo: repo.NewDefinitionsRepo(db),
|
||||
versions: repo.NewVersionsRepo(db),
|
||||
}
|
||||
}
|
||||
|
||||
// WithToolCheck teaches the service which tool names exist.
|
||||
//
|
||||
// §3 requires an unknown tool name to fail validation at PUBLISH. Without it
|
||||
// the runtime records the name and drops it, so a typo produces an agent that
|
||||
// is silently missing a capability its author believes it has — and the author
|
||||
// finds out by watching it fail to answer.
|
||||
//
|
||||
// Injected rather than imported so this package does not depend on the tool
|
||||
// registry, and so a test can supply its own vocabulary.
|
||||
func (s *DefinitionsService) WithToolCheck(unknown func([]string) []string) *DefinitionsService {
|
||||
s.unknownTools = unknown
|
||||
return s
|
||||
}
|
||||
|
||||
// rejectUnknownTools fails a definition that names a tool that does not exist.
|
||||
func (s *DefinitionsService) rejectUnknownTools(markdown string) error {
|
||||
if s.unknownTools == nil {
|
||||
return nil
|
||||
}
|
||||
agent, err := definition.ParseAgent(markdown, definition.Options{})
|
||||
if err != nil || agent == nil {
|
||||
// ValidateAgent has already run and reported anything real; a parse
|
||||
// failure here is not a second opinion worth raising.
|
||||
return nil
|
||||
}
|
||||
if bad := s.unknownTools(agent.Tools); len(bad) > 0 {
|
||||
return domain.Validation(
|
||||
fmt.Sprintf("unknown tool(s): %s", strings.Join(bad, ", ")),
|
||||
map[string]string{"tools": strings.Join(bad, ", ")})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseListParams validates query parameters for listing definitions.
|
||||
@@ -148,6 +192,9 @@ func (s *DefinitionsService) CreateAgent(ctx context.Context, ident authctx.Iden
|
||||
if err := definition.ValidateAgent(markdown); err != nil {
|
||||
return nil, domain.Validation(err.Error(), nil)
|
||||
}
|
||||
if err := s.rejectUnknownTools(markdown); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
visibility := "personal"
|
||||
if visRaw, ok := body["visibility"]; ok && visRaw != nil {
|
||||
@@ -187,7 +234,15 @@ func (s *DefinitionsService) CreateAgent(ctx context.Context, ident authctx.Iden
|
||||
input.OwnerUserID = &ident.UserID
|
||||
}
|
||||
|
||||
return s.repo.InsertAgent(ctx, ident, input)
|
||||
rec, err := s.repo.InsertAgent(ctx, ident, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Recorded after the row exists, and never allowed to fail the save: the
|
||||
// author's work is already stored, and losing it to protect a record of it
|
||||
// would be the wrong trade.
|
||||
_ = s.snapshotIfPublished(ctx, ident, repo.KindAgent, rec)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// UpdateAgent validates and applies updates to an authored agent definition.
|
||||
@@ -227,6 +282,9 @@ func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Iden
|
||||
if err := definition.ValidateAgent(markdown); err != nil {
|
||||
return nil, domain.Validation(err.Error(), nil)
|
||||
}
|
||||
if err := s.rejectUnknownTools(markdown); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agent, err := definition.ParseAgent(markdown, definition.Options{})
|
||||
if err != nil {
|
||||
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
|
||||
@@ -246,7 +304,12 @@ func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Iden
|
||||
input.Status = &status
|
||||
}
|
||||
|
||||
return s.repo.UpdateAgent(ctx, ident, id, input)
|
||||
rec, err := s.repo.UpdateAgent(ctx, ident, id, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.snapshotIfPublished(ctx, ident, repo.KindAgent, rec)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent definition following idempotent delete semantics.
|
||||
@@ -449,3 +512,87 @@ func (s *DefinitionsService) DeleteSkill(ctx context.Context, ident authctx.Iden
|
||||
}
|
||||
return domain.Record{"id": id}, nil
|
||||
}
|
||||
|
||||
/* ── Publishing ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// snapshotIfPublished records an immutable copy when a definition is published.
|
||||
//
|
||||
// §3: editing publishes a NEW version, and a published version never changes.
|
||||
// This is the half that records it. The half that enforces it is a trigger on
|
||||
// the table, because the repository is not the only thing that can reach it.
|
||||
//
|
||||
// ONLY ON PUBLISH. A draft is a work in progress and snapshotting every save
|
||||
// would fill the history with keystrokes — the version number would stop
|
||||
// meaning "a thing somebody decided to ship" and start meaning "a time somebody
|
||||
// pressed save", which is the number a run records and a person has to
|
||||
// recognise.
|
||||
//
|
||||
// A failure here does NOT fail the save. The definition is already written;
|
||||
// refusing the whole operation because its history could not be recorded would
|
||||
// lose the author's work to protect a record of it. It is returned so the
|
||||
// caller can log it, and the missing version shows up as a gap rather than as
|
||||
// a wrong answer.
|
||||
func (s *DefinitionsService) snapshotIfPublished(ctx context.Context, ident authctx.Identity,
|
||||
kind repo.VersionKind, rec domain.Record) error {
|
||||
|
||||
if s.versions == nil || rec == nil {
|
||||
return nil
|
||||
}
|
||||
status, _ := rec["status"].(string)
|
||||
if status != "published" {
|
||||
return nil
|
||||
}
|
||||
|
||||
markdown, _ := rec["markdown"].(string)
|
||||
definitionID, _ := rec["definition_id"].(string)
|
||||
if markdown == "" || definitionID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
version := 1
|
||||
switch v := rec["version"].(type) {
|
||||
case int:
|
||||
version = v
|
||||
case int32:
|
||||
version = int(v)
|
||||
case int64:
|
||||
version = int(v)
|
||||
case float64:
|
||||
version = int(v)
|
||||
}
|
||||
|
||||
name, _ := rec["name"].(string)
|
||||
description, _ := rec["description"].(string)
|
||||
var pages []string
|
||||
if raw, ok := rec["pages"].([]string); ok {
|
||||
pages = raw
|
||||
} else if raw, ok := rec["pages"].([]any); ok {
|
||||
for _, p := range raw {
|
||||
if str, ok := p.(string); ok {
|
||||
pages = append(pages, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.versions.Snapshot(ctx, ident, repo.SnapshotInput{
|
||||
Kind: kind,
|
||||
DefinitionID: definitionID,
|
||||
Version: version,
|
||||
Markdown: markdown,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Pages: pages,
|
||||
})
|
||||
}
|
||||
|
||||
// AgentHistory lists an agent's published versions, newest first.
|
||||
func (s *DefinitionsService) AgentHistory(ctx context.Context, ident authctx.Identity,
|
||||
definitionID string, limit int) ([]repo.Version, error) {
|
||||
return s.versions.History(ctx, ident, repo.KindAgent, definitionID, limit)
|
||||
}
|
||||
|
||||
// AgentVersion loads one published version of an agent, as it was.
|
||||
func (s *DefinitionsService) AgentVersion(ctx context.Context, ident authctx.Identity,
|
||||
definitionID string, version int) (*repo.Version, error) {
|
||||
return s.versions.Load(ctx, ident, repo.KindAgent, definitionID, version)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/krow/krow-backend/go-api/internal/definition"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/owliver"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// allowedSuggestionParams names the accepted query parameters, on the pattern
|
||||
@@ -37,23 +39,41 @@ type SuggestionQuery struct {
|
||||
|
||||
// SuggestionsService answers "what could I usefully ask on this page?".
|
||||
//
|
||||
// It holds no pool, opens no transaction and reads no table. That is not an
|
||||
// omission — the panel calls it while the user types, and everything it needs
|
||||
// is the static catalogue in internal/owliver plus the caller's role. It is a
|
||||
// service rather than a function in the handler so that validation and
|
||||
// authorization sit where every other endpoint's do.
|
||||
type SuggestionsService struct{}
|
||||
// It answers two different questions through one endpoint, and the difference
|
||||
// is whether anything was typed:
|
||||
//
|
||||
// - Something typed — ranked against the static catalogue in
|
||||
// internal/owliver. No database, because the panel issues one of these per
|
||||
// keystroke and the work has to stay a few string comparisons.
|
||||
// - Nothing typed — ranked against the ORGANIZATION'S ACTUAL STATE, read from
|
||||
// PostgreSQL here. That is one query per opened panel, and it is what makes
|
||||
// a suggestion react to the data: a workspace with three unfinished drafts
|
||||
// is asked a different question from one with eleven candidates waiting on
|
||||
// a decision, and creating a position changes what comes back next time.
|
||||
//
|
||||
// The pool is held for the second path only. Built without one — NewSuggestions
|
||||
// with a nil pool — the service still serves the typed path exactly as before,
|
||||
// which is what keeps it constructible where there is no database.
|
||||
type SuggestionsService struct {
|
||||
db repo.Querier
|
||||
}
|
||||
|
||||
// NewSuggestions builds the suggestion service.
|
||||
func NewSuggestions() *SuggestionsService { return &SuggestionsService{} }
|
||||
// NewSuggestions builds the suggestion service over a pool.
|
||||
//
|
||||
// A nil pool is legal and means "no organization context": the typed path is
|
||||
// unaffected and the untyped path answers with an empty list rather than
|
||||
// guessing. Nothing here invents context to make suggestions appear.
|
||||
func NewSuggestions(db repo.Querier) *SuggestionsService {
|
||||
return &SuggestionsService{db: db}
|
||||
}
|
||||
|
||||
// ParseParams validates the query string.
|
||||
//
|
||||
// `page` is required and must name a real surface — the same closed vocabulary
|
||||
// internal/definition validates a definition's `pages:` against, so there is
|
||||
// one answer to "is that a page" in this process. `query` is optional: an
|
||||
// absent or too-short one is not an error, it is a request that has nothing to
|
||||
// rank yet, and Suggest answers it with an empty list.
|
||||
// absent one is a request for what the data itself suggests rather than an
|
||||
// error.
|
||||
func (s *SuggestionsService) ParseParams(q url.Values) (SuggestionQuery, error) {
|
||||
var out SuggestionQuery
|
||||
|
||||
@@ -89,15 +109,92 @@ func (s *SuggestionsService) ParseParams(q url.Values) (SuggestionQuery, error)
|
||||
// reads it, and an unrecognised role is offered nothing — the same deny-by-
|
||||
// default the policy table applies. Nothing else about the caller is consulted:
|
||||
// there is no branch here on organization, account type or anything a request
|
||||
// could set.
|
||||
// could set, beyond the organization whose rows are counted.
|
||||
//
|
||||
// No error case beyond parsing. A page with no readings for this caller, and a
|
||||
// query that matches none of them, both answer with an empty list — an empty
|
||||
// result is an answer, not a failure.
|
||||
func (s *SuggestionsService) Suggest(ident authctx.Identity, q SuggestionQuery) []owliver.Suggestion {
|
||||
// No error case beyond parsing. A page with no readings for this caller, a
|
||||
// query that matches none of them, and an organization with nothing worth
|
||||
// remarking on all answer with an empty list — an empty result is an answer,
|
||||
// not a failure. A context read that fails degrades to that same empty list:
|
||||
// the panel opening with no suggestions is a smaller failure than the panel
|
||||
// refusing to open, and a fixed list would read as a real finding.
|
||||
func (s *SuggestionsService) Suggest(ctx context.Context, ident authctx.Identity, q SuggestionQuery) []owliver.Suggestion {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known {
|
||||
return []owliver.Suggestion{}
|
||||
}
|
||||
return owliver.Suggest(q.Page, q.Query, role)
|
||||
|
||||
// Anything typed is a question about words, answered from the catalogue.
|
||||
// Deliberately checked here rather than inside Suggest so that a query too
|
||||
// short to rank does NOT fall through to the untyped path: the user is
|
||||
// mid-word, and replacing what they are typing towards with three unrelated
|
||||
// readings of the database is the flicker this ordering avoids.
|
||||
if strings.TrimSpace(q.Query) != "" {
|
||||
return owliver.Suggest(q.Page, q.Query, role)
|
||||
}
|
||||
|
||||
return owliver.Highlights(q.Page, s.contextFor(ctx, ident, role), role)
|
||||
}
|
||||
|
||||
/* ── Organization context ───────────────────────────────────────────────── */
|
||||
|
||||
// contextQuery counts the organization, once.
|
||||
//
|
||||
// One statement rather than fifteen, because it is on the path that opens the
|
||||
// panel and fifteen round trips would be felt. Every count is a scalar subquery
|
||||
// over one org's rows, so nothing here can return a record, a name or an id —
|
||||
// see the note on owliver.Context.
|
||||
//
|
||||
// `starved_positions` and `underfilled_active` are the two that are not a plain
|
||||
// tally: a posting nobody has applied to, and a posting with fewer people
|
||||
// placed than it asked for. Those are the states that make a position worth
|
||||
// raising unprompted, and they are computed in SQL because the alternative is
|
||||
// reading every posting and every application into memory to count two
|
||||
// integers.
|
||||
const contextQuery = `
|
||||
SELECT
|
||||
(SELECT count(*) FROM job_postings WHERE org_id = $1 AND status = 'active') AS active_positions,
|
||||
(SELECT count(*) FROM job_postings WHERE org_id = $1 AND status = 'draft') AS draft_positions,
|
||||
(SELECT count(*) FROM job_postings p WHERE p.org_id = $1 AND p.status = 'active'
|
||||
AND NOT EXISTS (SELECT 1 FROM job_applications a WHERE a.job_posting_id = p.id)) AS starved_positions,
|
||||
(SELECT count(*) FROM job_postings p WHERE p.org_id = $1 AND p.status = 'active'
|
||||
AND p.headcount > (SELECT count(*) FROM job_applications a
|
||||
WHERE a.job_posting_id = p.id AND a.status IN ('hired','assigned'))) AS underfilled_active,
|
||||
(SELECT count(*) FROM job_applications WHERE org_id = $1) AS applications,
|
||||
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'applied') AS unscreened,
|
||||
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'shortlisted') AS shortlisted,
|
||||
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'interview') AS interviewing,
|
||||
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'hired') AS hired,
|
||||
(SELECT count(*) FROM ai_interviews WHERE org_id = $1 AND cardinality(ai_flags) > 0) AS flagged_risks,
|
||||
(SELECT count(*) FROM staff WHERE org_id = $1) AS staff,
|
||||
(SELECT count(*) FROM worker_profiles WHERE org_id = $1) AS profiles,
|
||||
(SELECT count(*) FROM worker_profiles w WHERE w.org_id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM evidence e WHERE e.worker_email = w.email)) AS unverified_profiles,
|
||||
(SELECT count(*) FROM courses WHERE org_id = $1) AS courses,
|
||||
(SELECT count(*) FROM user_activity WHERE org_id = $1) AS activity_events
|
||||
`
|
||||
|
||||
// contextFor reads the organization's state, or gives up honestly.
|
||||
//
|
||||
// A talent caller gets the zero Context and no query is run. That is not a
|
||||
// performance choice: every count above is org-wide, and talent's rows are
|
||||
// narrowed by the policy table, so answering "eleven candidates are waiting" to
|
||||
// someone entitled to see one of them would leak the other ten through an
|
||||
// integer. Their own readings — the Profile page's — carry no org-wide Need and
|
||||
// are ranked without any of this.
|
||||
func (s *SuggestionsService) contextFor(ctx context.Context, ident authctx.Identity, role domain.Role) owliver.Context {
|
||||
var out owliver.Context
|
||||
if s.db == nil || ident.OrgID == "" || role == domain.RoleTalent {
|
||||
return out
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(ctx, contextQuery, ident.OrgID).Scan(
|
||||
&out.ActivePositions, &out.DraftPositions, &out.StarvedPositions, &out.UnderfilledActive,
|
||||
&out.Applications, &out.Unscreened, &out.Shortlisted, &out.Interviewing, &out.Hired,
|
||||
&out.FlaggedRisks,
|
||||
&out.Staff, &out.Profiles, &out.UnverifiedProfiles, &out.Courses, &out.ActivityEvents,
|
||||
)
|
||||
if err != nil {
|
||||
return owliver.Context{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user