Add async third-party vetting
Queue vetting on third_parties with PENDING, PROCESSING, COMPLETED, and FAILED states. Expose enqueue and status through GraphQL, MCP, CLI, and n8n, validate vet requests, tune the worker via config, and poll the detail page while vetting runs. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
const (
|
||||
// DefaultMaxTokens is the fallback max-tokens budget used when the
|
||||
// third-party-assessor agent config does not specify a value. Sized to
|
||||
// third-party-vetter agent config does not specify a value. Sized to
|
||||
// leave headroom above the orchestrator's thinking budget on
|
||||
// Anthropic models.
|
||||
DefaultMaxTokens = 16384
|
||||
@@ -173,7 +173,7 @@ func NewAssessor(cfg Config) *Assessor {
|
||||
return &Assessor{cfg: cfg}
|
||||
}
|
||||
|
||||
func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure string, reporter agent.ProgressReporter) (*Result, error) {
|
||||
func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure string, reporter agent.ProgressReporter, extraTools []agent.Tool) (*Result, error) {
|
||||
u, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse website URL %q: %w", websiteURL, err)
|
||||
@@ -193,15 +193,12 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), AssessmentTimeout)
|
||||
defer cancel()
|
||||
|
||||
thirdPartyBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
|
||||
defer thirdPartyBrowser.Close()
|
||||
|
||||
thirdPartyBrowser.SetAllowedDomain(u.Hostname())
|
||||
|
||||
// Create an unrestricted browser for web search agents that need to
|
||||
// follow links to external sites (news, reviews, etc.).
|
||||
researchBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
|
||||
defer researchBrowser.Close()
|
||||
// One shared remote Chrome allocator for all sub-agents. Sub-agents
|
||||
// that need external links (subprocessor hosts, research) share it
|
||||
// with vendor-site crawlers. Navigation is still gated by public-IP
|
||||
// checks; we do not pin an allowed domain so external follows work.
|
||||
webBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
|
||||
defer webBrowser.Close()
|
||||
|
||||
orchestrator, err := newOrchestratorAgent(
|
||||
a.cfg.Client,
|
||||
@@ -209,16 +206,16 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
|
||||
a.cfg.MaxTokens,
|
||||
procedure,
|
||||
a.cfg.Logger,
|
||||
thirdPartyBrowser,
|
||||
researchBrowser,
|
||||
webBrowser,
|
||||
a.cfg.FirecrawlAPIKey,
|
||||
reporter,
|
||||
extraTools,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create orchestrator agent: %w", err)
|
||||
}
|
||||
|
||||
result, err := orchestrator.Run(
|
||||
orchestratorResult, err := orchestrator.Run(
|
||||
ctx,
|
||||
[]llm.Message{
|
||||
{
|
||||
@@ -231,7 +228,10 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
|
||||
return nil, fmt.Errorf("cannot assess thirdParty: %w", err)
|
||||
}
|
||||
|
||||
document := result.FinalMessage().Text()
|
||||
document := orchestratorResult.FinalMessage().Text()
|
||||
|
||||
// Extraction is LLM-only; release Chrome before it runs.
|
||||
webBrowser.Close()
|
||||
|
||||
reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepStarted)
|
||||
|
||||
@@ -241,6 +241,13 @@ func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure stri
|
||||
return nil, fmt.Errorf("cannot extract thirdParty info: %w", err)
|
||||
}
|
||||
|
||||
toolSubprocessors := subprocessorsFromOrchestratorMessages(orchestratorResult.Messages)
|
||||
info.Subprocessors = mergeSubprocessors(toolSubprocessors, info.Subprocessors)
|
||||
|
||||
if info.SubprocessorsListURL == "" {
|
||||
info.SubprocessorsListURL = subprocessorListURLFromOrchestratorMessages(orchestratorResult.Messages)
|
||||
}
|
||||
|
||||
reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepCompleted)
|
||||
|
||||
return &Result{
|
||||
@@ -335,7 +342,12 @@ func thirdPartyInfoOutputType() (*agent.OutputType, error) {
|
||||
return nil, fmt.Errorf("cannot marshal decorated thirdParty info schema: %w", err)
|
||||
}
|
||||
|
||||
outputType.Schema = decorated
|
||||
strict, err := enforceStrictJSONSchema(decorated)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot enforce strict thirdParty info schema: %w", err)
|
||||
}
|
||||
|
||||
outputType.Schema = strict
|
||||
|
||||
return outputType, nil
|
||||
}
|
||||
|
||||
164
pkg/vetting/country_codes.go
Normal file
164
pkg/vetting/country_codes.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
var countryAliases = map[string]coredata.CountryCode{
|
||||
"global": coredata.CountryCodeGlobal,
|
||||
"global presence": coredata.CountryCodeGlobal,
|
||||
"worldwide": coredata.CountryCodeGlobal,
|
||||
"international": coredata.CountryCodeGlobal,
|
||||
"multiple regions": coredata.CountryCodeGlobal,
|
||||
"eu": coredata.CountryCodeEU,
|
||||
"european union": coredata.CountryCodeEU,
|
||||
"europe": coredata.CountryCodeEU,
|
||||
"united states": coredata.CountryCodeUS,
|
||||
"united states usa": coredata.CountryCodeUS,
|
||||
"usa": coredata.CountryCodeUS,
|
||||
"u.s.": coredata.CountryCodeUS,
|
||||
"u.s.a.": coredata.CountryCodeUS,
|
||||
"us": coredata.CountryCodeUS,
|
||||
"united kingdom": coredata.CountryCodeGB,
|
||||
"uk": coredata.CountryCodeGB,
|
||||
"great britain": coredata.CountryCodeGB,
|
||||
"germany": coredata.CountryCodeDE,
|
||||
"france": coredata.CountryCodeFR,
|
||||
"canada": coredata.CountryCodeCA,
|
||||
"australia": coredata.CountryCodeAU,
|
||||
"japan": coredata.CountryCodeJP,
|
||||
"china": coredata.CountryCodeCN,
|
||||
"india": coredata.CountryCodeIN,
|
||||
"ireland": coredata.CountryCodeIE,
|
||||
"netherlands": coredata.CountryCodeNL,
|
||||
"singapore": coredata.CountryCodeSG,
|
||||
"switzerland": coredata.CountryCodeCH,
|
||||
"sweden": coredata.CountryCodeSE,
|
||||
"spain": coredata.CountryCodeES,
|
||||
"italy": coredata.CountryCodeIT,
|
||||
"brazil": coredata.CountryCodeBR,
|
||||
"mexico": coredata.CountryCodeMX,
|
||||
"south korea": coredata.CountryCodeKR,
|
||||
"korea": coredata.CountryCodeKR,
|
||||
}
|
||||
|
||||
func parseOptionalCountryCodes(raw string) coredata.CountryCodes {
|
||||
code, ok := parseCountryLocation(raw)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return coredata.CountryCodes{code}
|
||||
}
|
||||
|
||||
func countriesFromInfo(info ThirdPartyInfo) coredata.CountryCodes {
|
||||
raw := append([]string{}, info.DataLocations...)
|
||||
if info.HeadquarterAddress != "" {
|
||||
raw = append(raw, info.HeadquarterAddress)
|
||||
}
|
||||
|
||||
return parseCountryLocations(raw...)
|
||||
}
|
||||
|
||||
func parseCountryLocations(raw ...string) coredata.CountryCodes {
|
||||
seen := make(map[coredata.CountryCode]struct{})
|
||||
out := make(coredata.CountryCodes, 0, len(raw))
|
||||
|
||||
for _, value := range raw {
|
||||
for _, part := range splitCountryList(value) {
|
||||
code, ok := parseCountryLocation(part)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := seen[code]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[code] = struct{}{}
|
||||
out = append(out, code)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func parseCountryLocation(raw string) (coredata.CountryCode, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
code := coredata.CountryCode(strings.ToUpper(raw))
|
||||
if code.IsValid() {
|
||||
return code, true
|
||||
}
|
||||
|
||||
if mapped, ok := countryAliases[normalizeCountryKey(raw)]; ok {
|
||||
return mapped, true
|
||||
}
|
||||
|
||||
if strings.Contains(raw, ",") {
|
||||
parts := strings.Split(raw, ",")
|
||||
last := strings.TrimSpace(parts[len(parts)-1])
|
||||
|
||||
if mapped, ok := countryAliases[normalizeCountryKey(last)]; ok {
|
||||
return mapped, true
|
||||
}
|
||||
|
||||
lastCode := coredata.CountryCode(strings.ToUpper(last))
|
||||
if lastCode.IsValid() {
|
||||
return lastCode, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitCountryList(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, sep := range []string{";", "|", "/", " and ", " & "} {
|
||||
if strings.Contains(strings.ToLower(raw), sep) {
|
||||
parts := strings.Split(raw, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
return []string{raw}
|
||||
}
|
||||
|
||||
func normalizeCountryKey(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
raw = strings.TrimPrefix(raw, "the ")
|
||||
|
||||
return strings.Join(strings.Fields(raw), " ")
|
||||
}
|
||||
78
pkg/vetting/country_codes_test.go
Normal file
78
pkg/vetting/country_codes_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestParseCountryLocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
raw string
|
||||
expected coredata.CountryCode
|
||||
}{
|
||||
{raw: "US", expected: coredata.CountryCodeUS},
|
||||
{raw: "usa", expected: coredata.CountryCodeUS},
|
||||
{raw: "United States", expected: coredata.CountryCodeUS},
|
||||
{raw: "Seattle, Washington, USA", expected: coredata.CountryCodeUS},
|
||||
{raw: "Global presence", expected: coredata.CountryCodeGlobal},
|
||||
{raw: "EU", expected: coredata.CountryCodeEU},
|
||||
{raw: "Germany", expected: coredata.CountryCodeDE},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.raw, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code, ok := parseCountryLocation(tt.raw)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tt.expected, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountriesFromInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
countries := countriesFromInfo(ThirdPartyInfo{
|
||||
HeadquarterAddress: "Seattle, Washington, USA",
|
||||
DataLocations: []string{"Germany", "EU"},
|
||||
})
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
coredata.CountryCodes{
|
||||
coredata.CountryCodeDE,
|
||||
coredata.CountryCodeEU,
|
||||
coredata.CountryCodeUS,
|
||||
},
|
||||
countries,
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseOptionalCountryCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
coredata.CountryCodes{coredata.CountryCodeFR},
|
||||
parseOptionalCountryCodes("France"),
|
||||
)
|
||||
}
|
||||
189
pkg/vetting/openai_schema.go
Normal file
189
pkg/vetting/openai_schema.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type strictFunctionTool[P any] struct {
|
||||
name string
|
||||
description string
|
||||
fn func(ctx context.Context, params P) (agent.ToolResult, error)
|
||||
schema json.RawMessage
|
||||
requiredFields []string
|
||||
}
|
||||
|
||||
// jsonSchemaForTool builds an OpenAI strict-mode JSON schema for vetting tools
|
||||
// and structured outputs. OpenAI requires every property in required and
|
||||
// additionalProperties=false; the shared agent schema generator does not.
|
||||
func jsonSchemaForTool[T any]() (json.RawMessage, error) {
|
||||
outputType, err := agent.NewOutputType[T]("_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate schema: %w", err)
|
||||
}
|
||||
|
||||
return enforceStrictJSONSchema(outputType.Schema)
|
||||
}
|
||||
|
||||
func newVettingOutputType[T any](name string) (*agent.OutputType, error) {
|
||||
outputType, err := agent.NewOutputType[T](name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schema, err := enforceStrictJSONSchema(outputType.Schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot enforce strict schema for %q: %w", name, err)
|
||||
}
|
||||
|
||||
outputType.Schema = schema
|
||||
|
||||
return outputType, nil
|
||||
}
|
||||
|
||||
func vettingFunctionTool[P any](
|
||||
name string,
|
||||
description string,
|
||||
fn func(ctx context.Context, params P) (agent.ToolResult, error),
|
||||
) agent.Tool {
|
||||
schema, err := jsonSchemaForTool[P]()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("vetting: cannot generate JSON schema for tool %q: %s", name, err))
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
if err := json.Unmarshal(schema, &parsed); err != nil {
|
||||
panic(fmt.Sprintf("vetting: cannot parse generated schema for tool %q: %s", name, err))
|
||||
}
|
||||
|
||||
return &strictFunctionTool[P]{
|
||||
name: name,
|
||||
description: description,
|
||||
fn: fn,
|
||||
schema: schema,
|
||||
requiredFields: parsed.Required,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *strictFunctionTool[P]) Name() string { return t.name }
|
||||
|
||||
func (t *strictFunctionTool[P]) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Name: t.name,
|
||||
Description: t.description,
|
||||
Parameters: t.schema,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *strictFunctionTool[P]) Execute(ctx context.Context, arguments string) (agent.ToolResult, error) {
|
||||
if len(t.requiredFields) > 0 {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(arguments), &fields); err != nil {
|
||||
return agent.ToolResult{
|
||||
Content: fmt.Sprintf("Invalid parameters: %s", err.Error()),
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var missing []string
|
||||
|
||||
for _, f := range t.requiredFields {
|
||||
if _, ok := fields[f]; !ok {
|
||||
missing = append(missing, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return agent.ToolResult{
|
||||
Content: fmt.Sprintf(
|
||||
"Missing required parameters: %s",
|
||||
strings.Join(missing, ", "),
|
||||
),
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var params P
|
||||
if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil {
|
||||
return agent.ToolResult{
|
||||
Content: fmt.Sprintf("Invalid parameters: %s", err.Error()),
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return t.fn(ctx, params)
|
||||
}
|
||||
|
||||
func enforceStrictJSONSchema(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
return nil, fmt.Errorf("cannot unmarshal schema: %w", err)
|
||||
}
|
||||
|
||||
normalizeStrictObject(schema)
|
||||
|
||||
data, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal strict schema: %w", err)
|
||||
}
|
||||
|
||||
return json.RawMessage(data), nil
|
||||
}
|
||||
|
||||
func normalizeStrictObject(schema map[string]any) {
|
||||
if schema == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if props, ok := schema["properties"].(map[string]any); ok && len(props) > 0 {
|
||||
required := make([]string, 0, len(props))
|
||||
for name, prop := range props {
|
||||
required = append(required, name)
|
||||
|
||||
if nested, ok := prop.(map[string]any); ok {
|
||||
normalizeStrictObject(nested)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(required)
|
||||
|
||||
requiredAny := make([]any, len(required))
|
||||
for i, name := range required {
|
||||
requiredAny[i] = name
|
||||
}
|
||||
|
||||
schema["required"] = requiredAny
|
||||
schema["additionalProperties"] = false
|
||||
}
|
||||
|
||||
if items, ok := schema["items"].(map[string]any); ok {
|
||||
normalizeStrictObject(items)
|
||||
}
|
||||
|
||||
if additional, ok := schema["additionalProperties"].(map[string]any); ok {
|
||||
normalizeStrictObject(additional)
|
||||
}
|
||||
}
|
||||
51
pkg/vetting/openai_schema_test.go
Normal file
51
pkg/vetting/openai_schema_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJSONSchemaForTool_EnforcesOpenAIStrictMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw, err := jsonSchemaForTool[saveThirdPartyInfoToolParams]()
|
||||
require.NoError(t, err)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
required := schema["required"].([]any)
|
||||
assert.Contains(t, required, "name")
|
||||
assert.Contains(t, required, "description")
|
||||
assert.Equal(t, false, schema["additionalProperties"])
|
||||
}
|
||||
|
||||
func TestNewVettingOutputType_EnforcesOpenAIStrictMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outputType, err := newVettingOutputType[CrawlerOutput]("crawler")
|
||||
require.NoError(t, err)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(outputType.Schema, &schema))
|
||||
|
||||
assert.Equal(t, false, schema["additionalProperties"])
|
||||
assert.NotEmpty(t, schema["required"])
|
||||
}
|
||||
@@ -63,17 +63,16 @@ func newOrchestratorAgent(
|
||||
maxTokens int,
|
||||
procedure string,
|
||||
logger *log.Logger,
|
||||
thirdPartyBrowser *browser.Browser,
|
||||
researchBrowser *browser.Browser,
|
||||
webBrowser *browser.Browser,
|
||||
firecrawlAPIKey string,
|
||||
reporter agent.ProgressReporter,
|
||||
extraTools []agent.Tool,
|
||||
) (*agent.Agent, error) {
|
||||
readOnlyBrowserTools := browser.NewReadOnlyToolset(thirdPartyBrowser).Tools()
|
||||
readOnlyBrowserTools := browser.NewReadOnlyToolset(webBrowser).Tools()
|
||||
|
||||
// Unrestricted browser tools for sub-agents that need to follow links
|
||||
// to external sites (subprocessor lists hosted on OneTrust/Transcend,
|
||||
// research, thirdParty comparison).
|
||||
unrestrictedBrowserTools := browser.NewInteractiveToolset(researchBrowser).Tools()
|
||||
// Interactive browser tools for sub-agents that follow links off the
|
||||
// vendor site (subprocessor lists on OneTrust/Transcend, research).
|
||||
unrestrictedBrowserTools := browser.NewInteractiveToolset(webBrowser).Tools()
|
||||
|
||||
securityTools := security.NewToolset().Tools()
|
||||
|
||||
@@ -176,7 +175,7 @@ func newOrchestratorAgent(
|
||||
|
||||
// Optional sub-agents: only added when Firecrawl is configured.
|
||||
if hasFirecrawl {
|
||||
researchBrowserTools := browser.NewInteractiveToolset(researchBrowser).Tools()
|
||||
researchBrowserTools := browser.NewInteractiveToolset(webBrowser).Tools()
|
||||
|
||||
searchTool := search.FirecrawlSearchTool(firecrawlAPIKey)
|
||||
govDBTool := search.CheckGovernmentDBTool(firecrawlAPIKey)
|
||||
@@ -228,7 +227,7 @@ func newOrchestratorAgent(
|
||||
)
|
||||
}
|
||||
|
||||
tools := make([]agent.Tool, 0, len(entries))
|
||||
tools := make([]agent.Tool, 0, len(entries)+len(extraTools))
|
||||
for _, e := range entries {
|
||||
ag, err := e.build(client, model, e.tools, subAgentOpts(e.toolName)...)
|
||||
if err != nil {
|
||||
@@ -238,6 +237,8 @@ func newOrchestratorAgent(
|
||||
tools = append(tools, ag.AsTool(e.toolName, e.description))
|
||||
}
|
||||
|
||||
tools = append(tools, extraTools...)
|
||||
|
||||
if procedure == "" {
|
||||
procedure = defaultProcedure
|
||||
}
|
||||
|
||||
448
pkg/vetting/persist.go
Normal file
448
pkg/vetting/persist.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
const (
|
||||
vettingRiskAssessmentValidity = 365 * 24 * time.Hour
|
||||
maxVettingNotesGaps = 5
|
||||
)
|
||||
|
||||
// PersistAssessmentResult writes extracted assessment metadata onto the parent
|
||||
// third party, links any sub-processors, and stores the risk assessment in one
|
||||
// short transaction after the long assess phase completes. The assess run
|
||||
// itself does not touch the database.
|
||||
func PersistAssessmentResult(
|
||||
ctx context.Context,
|
||||
pc *PersistenceContext,
|
||||
result Result,
|
||||
) error {
|
||||
scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID)
|
||||
|
||||
return pc.PG.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
thirdParty := &coredata.ThirdParty{}
|
||||
|
||||
if err := thirdParty.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load third party: %w", err)
|
||||
}
|
||||
|
||||
applySaveParams(thirdParty, pc.WebsiteURL, saveParamsFromInfo(result.Info))
|
||||
thirdParty.UpdatedAt = time.Now()
|
||||
|
||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot update third party: %w", err)
|
||||
}
|
||||
|
||||
for _, sub := range result.Info.Subprocessors {
|
||||
if sub.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := linkSubThirdParty(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
pc,
|
||||
linkSubThirdPartyParams{
|
||||
Name: sub.Name,
|
||||
Country: sub.Country,
|
||||
Purpose: sub.Purpose,
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot link sub third party %q: %w", sub.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := persistVettingRiskAssessment(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
pc,
|
||||
thirdParty,
|
||||
result,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot persist vetting risk assessment: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func persistVettingRiskAssessment(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope coredata.Scoper,
|
||||
pc *PersistenceContext,
|
||||
thirdParty *coredata.ThirdParty,
|
||||
result Result,
|
||||
) error {
|
||||
if err := thirdParty.ExpireNonExpiredRiskAssessments(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot expire existing risk assessments: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
notes := buildRiskAssessmentNotes(result.Info)
|
||||
|
||||
assessment := &coredata.ThirdPartyRiskAssessment{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyRiskAssessmentEntityType),
|
||||
OrganizationID: pc.OrganizationID,
|
||||
ThirdPartyID: pc.ThirdPartyID,
|
||||
ExpiresAt: now.Add(vettingRiskAssessmentValidity),
|
||||
DataSensitivity: mapVettingDataSensitivity(result.Info),
|
||||
BusinessImpact: mapVettingBusinessImpact(result.Info),
|
||||
Notes: ¬es,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := assessment.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert risk assessment: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildRiskAssessmentNotes(info ThirdPartyInfo) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("Automated vetting\n\n")
|
||||
|
||||
switch {
|
||||
case info.OverallRiskRating != "" && info.OverallRiskScore > 0:
|
||||
fmt.Fprintf(
|
||||
&b,
|
||||
"Overall risk: %d/100 (%s)\n",
|
||||
info.OverallRiskScore,
|
||||
info.OverallRiskRating,
|
||||
)
|
||||
case info.OverallRiskScore > 0:
|
||||
fmt.Fprintf(&b, "Overall risk: %d/100\n", info.OverallRiskScore)
|
||||
case info.OverallRiskRating != "":
|
||||
fmt.Fprintf(&b, "Overall risk: %s\n", info.OverallRiskRating)
|
||||
}
|
||||
|
||||
if info.Recommendation != "" {
|
||||
fmt.Fprintf(&b, "Recommendation: %s\n", formatVettingRecommendation(info.Recommendation))
|
||||
}
|
||||
|
||||
var scoreParts []string
|
||||
|
||||
if info.SecurityRiskScore > 0 {
|
||||
scoreParts = append(scoreParts, fmt.Sprintf("Security %d/100", info.SecurityRiskScore))
|
||||
}
|
||||
|
||||
if info.PrivacyRiskScore > 0 {
|
||||
scoreParts = append(scoreParts, fmt.Sprintf("Privacy %d/100", info.PrivacyRiskScore))
|
||||
}
|
||||
|
||||
if info.InvolvesAI || info.AIRiskScore > 0 {
|
||||
scoreParts = append(scoreParts, fmt.Sprintf("AI %d/100", info.AIRiskScore))
|
||||
}
|
||||
|
||||
if len(scoreParts) > 0 {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(strings.Join(scoreParts, " · "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
if len(info.InformationGaps) > 0 {
|
||||
b.WriteString("\nGaps\n")
|
||||
|
||||
gaps := info.InformationGaps
|
||||
if len(gaps) > maxVettingNotesGaps {
|
||||
gaps = gaps[:maxVettingNotesGaps]
|
||||
}
|
||||
|
||||
for _, gap := range gaps {
|
||||
fmt.Fprintf(&b, "· %s\n", strings.TrimSpace(gap))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func formatVettingRecommendation(recommendation string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(recommendation)) {
|
||||
case "APPROVE":
|
||||
return "Approve"
|
||||
case "APPROVE_WITH_CONDITIONS":
|
||||
return "Approve with conditions"
|
||||
case "ESCALATE":
|
||||
return "Escalate"
|
||||
case "REJECT":
|
||||
return "Reject"
|
||||
default:
|
||||
return recommendation
|
||||
}
|
||||
}
|
||||
|
||||
func mapVettingDataSensitivity(info ThirdPartyInfo) coredata.DataSensitivity {
|
||||
if !info.ProcessesPII && info.PrivacyRiskScore == 0 {
|
||||
return coredata.DataSensitivityNone
|
||||
}
|
||||
|
||||
score := info.PrivacyRiskScore
|
||||
if score == 0 {
|
||||
score = overallScoreFromRating(info.OverallRiskRating)
|
||||
}
|
||||
|
||||
return scoreToDataSensitivity(score)
|
||||
}
|
||||
|
||||
func mapVettingBusinessImpact(info ThirdPartyInfo) coredata.BusinessImpact {
|
||||
score := info.OverallRiskScore
|
||||
if score == 0 {
|
||||
score = info.SecurityRiskScore
|
||||
}
|
||||
|
||||
if score == 0 {
|
||||
score = overallScoreFromRating(info.OverallRiskRating)
|
||||
}
|
||||
|
||||
return scoreToBusinessImpact(score)
|
||||
}
|
||||
|
||||
func overallScoreFromRating(rating string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(rating)) {
|
||||
case "low":
|
||||
return 25
|
||||
case "medium":
|
||||
return 50
|
||||
case "high":
|
||||
return 75
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func scoreToDataSensitivity(score int) coredata.DataSensitivity {
|
||||
switch {
|
||||
case score <= 0:
|
||||
return coredata.DataSensitivityNone
|
||||
case score <= 25:
|
||||
return coredata.DataSensitivityLow
|
||||
case score <= 50:
|
||||
return coredata.DataSensitivityMedium
|
||||
case score <= 75:
|
||||
return coredata.DataSensitivityHigh
|
||||
default:
|
||||
return coredata.DataSensitivityCritical
|
||||
}
|
||||
}
|
||||
|
||||
func scoreToBusinessImpact(score int) coredata.BusinessImpact {
|
||||
switch {
|
||||
case score <= 25:
|
||||
return coredata.BusinessImpactLow
|
||||
case score <= 50:
|
||||
return coredata.BusinessImpactMedium
|
||||
case score <= 75:
|
||||
return coredata.BusinessImpactHigh
|
||||
default:
|
||||
return coredata.BusinessImpactCritical
|
||||
}
|
||||
}
|
||||
|
||||
func saveParamsFromInfo(info ThirdPartyInfo) saveThirdPartyInfoParams {
|
||||
return saveThirdPartyInfoParams{
|
||||
saveThirdPartyInfoToolParams: saveThirdPartyInfoToolParams{
|
||||
Name: info.Name,
|
||||
Description: info.Description,
|
||||
Category: info.Category,
|
||||
HeadquarterAddress: info.HeadquarterAddress,
|
||||
LegalName: info.LegalName,
|
||||
PrivacyPolicyURL: info.PrivacyPolicyURL,
|
||||
ServiceLevelAgreementURL: info.ServiceLevelAgreementURL,
|
||||
DataProcessingAgreementURL: info.DataProcessingAgreementURL,
|
||||
BusinessAssociateAgreementURL: info.BusinessAssociateAgreementURL,
|
||||
SubprocessorsListURL: info.SubprocessorsListURL,
|
||||
SecurityPageURL: info.SecurityPageURL,
|
||||
TrustPageURL: info.TrustPageURL,
|
||||
TermsOfServiceURL: info.TermsOfServiceURL,
|
||||
StatusPageURL: info.StatusPageURL,
|
||||
Certifications: info.Certifications,
|
||||
},
|
||||
Countries: countriesFromInfo(info),
|
||||
}
|
||||
}
|
||||
|
||||
func applySaveParams(
|
||||
thirdParty *coredata.ThirdParty,
|
||||
websiteURL string,
|
||||
p saveThirdPartyInfoParams,
|
||||
) {
|
||||
if p.Name != "" {
|
||||
thirdParty.Name = p.Name
|
||||
}
|
||||
|
||||
thirdParty.WebsiteURL = &websiteURL
|
||||
|
||||
if p.Category != "" {
|
||||
if category, err := parseThirdPartyCategory(p.Category); err == nil {
|
||||
thirdParty.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if p.Description != "" {
|
||||
thirdParty.Description = &p.Description
|
||||
}
|
||||
|
||||
if p.HeadquarterAddress != "" {
|
||||
thirdParty.HeadquarterAddress = &p.HeadquarterAddress
|
||||
}
|
||||
|
||||
if p.LegalName != "" {
|
||||
thirdParty.LegalName = &p.LegalName
|
||||
}
|
||||
|
||||
if p.PrivacyPolicyURL != "" {
|
||||
thirdParty.PrivacyPolicyURL = &p.PrivacyPolicyURL
|
||||
}
|
||||
|
||||
if p.ServiceLevelAgreementURL != "" {
|
||||
thirdParty.ServiceLevelAgreementURL = &p.ServiceLevelAgreementURL
|
||||
}
|
||||
|
||||
if p.DataProcessingAgreementURL != "" {
|
||||
thirdParty.DataProcessingAgreementURL = &p.DataProcessingAgreementURL
|
||||
}
|
||||
|
||||
if p.BusinessAssociateAgreementURL != "" {
|
||||
thirdParty.BusinessAssociateAgreementURL = &p.BusinessAssociateAgreementURL
|
||||
}
|
||||
|
||||
if p.SubprocessorsListURL != "" {
|
||||
thirdParty.SubprocessorsListURL = &p.SubprocessorsListURL
|
||||
}
|
||||
|
||||
if p.SecurityPageURL != "" {
|
||||
thirdParty.SecurityPageURL = &p.SecurityPageURL
|
||||
}
|
||||
|
||||
if p.TrustPageURL != "" {
|
||||
thirdParty.TrustPageURL = &p.TrustPageURL
|
||||
}
|
||||
|
||||
if p.TermsOfServiceURL != "" {
|
||||
thirdParty.TermsOfServiceURL = &p.TermsOfServiceURL
|
||||
}
|
||||
|
||||
if p.StatusPageURL != "" {
|
||||
thirdParty.StatusPageURL = &p.StatusPageURL
|
||||
}
|
||||
|
||||
if len(p.Certifications) > 0 {
|
||||
thirdParty.Certifications = p.Certifications
|
||||
}
|
||||
|
||||
if len(p.Countries) > 0 {
|
||||
thirdParty.Countries = p.Countries
|
||||
}
|
||||
}
|
||||
|
||||
func linkSubThirdParty(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope coredata.Scoper,
|
||||
pc *PersistenceContext,
|
||||
p linkSubThirdPartyParams,
|
||||
) error {
|
||||
if p.Name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
child := &coredata.ThirdParty{}
|
||||
|
||||
err := child.LoadByNameAndOrganizationID(ctx, conn, scope, p.Name, pc.OrganizationID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return fmt.Errorf("cannot find child third party %q: %w", p.Name, err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
child = &coredata.ThirdParty{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
||||
OrganizationID: pc.OrganizationID,
|
||||
Name: p.Name,
|
||||
Category: coredata.ThirdPartyCategoryOther,
|
||||
FirstLevel: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if p.Description != "" {
|
||||
child.Description = &p.Description
|
||||
}
|
||||
|
||||
if p.Category != "" {
|
||||
if category, err := parseThirdPartyCategory(p.Category); err == nil {
|
||||
child.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if p.WebsiteURL != "" {
|
||||
child.WebsiteURL = &p.WebsiteURL
|
||||
}
|
||||
|
||||
if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 {
|
||||
child.Countries = countries
|
||||
}
|
||||
|
||||
if err := child.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot create child third party %q: %w", p.Name, err)
|
||||
}
|
||||
} else if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 && len(child.Countries) == 0 {
|
||||
child.Countries = countries
|
||||
child.UpdatedAt = time.Now()
|
||||
|
||||
if err := child.Update(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot update child third party %q countries: %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if child.ID == pc.ThirdPartyID {
|
||||
return nil
|
||||
}
|
||||
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: pc.ThirdPartyID,
|
||||
ChildThirdPartyID: child.ID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if p.Purpose != "" {
|
||||
relation.Purpose = &p.Purpose
|
||||
}
|
||||
|
||||
if err := relation.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert third party relation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
108
pkg/vetting/persist_test.go
Normal file
108
pkg/vetting/persist_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestBuildRiskAssessmentNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := ThirdPartyInfo{
|
||||
OverallRiskRating: "Medium",
|
||||
OverallRiskScore: 62,
|
||||
Recommendation: "APPROVE_WITH_CONDITIONS",
|
||||
SecurityRiskScore: 45,
|
||||
PrivacyRiskScore: 70,
|
||||
AIRiskScore: 10,
|
||||
InvolvesAI: true,
|
||||
RiskScores: []RiskScore{
|
||||
{Category: "Security", Rating: "Medium", Notes: "Missing SOC 2"},
|
||||
},
|
||||
InformationGaps: []string{"No public DPA", "Sub-processor list inaccessible"},
|
||||
}
|
||||
|
||||
notes := buildRiskAssessmentNotes(info)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
`Automated vetting
|
||||
|
||||
Overall risk: 62/100 (Medium)
|
||||
Recommendation: Approve with conditions
|
||||
|
||||
Security 45/100 · Privacy 70/100 · AI 10/100
|
||||
|
||||
Gaps
|
||||
· No public DPA
|
||||
· Sub-processor list inaccessible`,
|
||||
notes,
|
||||
)
|
||||
assert.NotContains(t, notes, "**")
|
||||
assert.NotContains(t, notes, "#")
|
||||
}
|
||||
|
||||
func TestBuildRiskAssessmentNotes_LimitsGaps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gaps := make([]string, maxVettingNotesGaps+2)
|
||||
for i := range gaps {
|
||||
gaps[i] = "gap"
|
||||
}
|
||||
|
||||
notes := buildRiskAssessmentNotes(ThirdPartyInfo{InformationGaps: gaps})
|
||||
|
||||
assert.Equal(t, maxVettingNotesGaps, strings.Count(notes, "· gap"))
|
||||
}
|
||||
|
||||
func TestFormatVettingRecommendation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "Approve with conditions", formatVettingRecommendation("APPROVE_WITH_CONDITIONS"))
|
||||
assert.Equal(t, "Reject", formatVettingRecommendation("reject"))
|
||||
}
|
||||
|
||||
func TestMapVettingRiskLevels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
coredata.DataSensitivityNone,
|
||||
mapVettingDataSensitivity(ThirdPartyInfo{ProcessesPII: false}),
|
||||
)
|
||||
assert.Equal(
|
||||
t,
|
||||
coredata.DataSensitivityHigh,
|
||||
mapVettingDataSensitivity(ThirdPartyInfo{
|
||||
ProcessesPII: true,
|
||||
PrivacyRiskScore: 70,
|
||||
}),
|
||||
)
|
||||
assert.Equal(
|
||||
t,
|
||||
coredata.BusinessImpactMedium,
|
||||
mapVettingBusinessImpact(ThirdPartyInfo{OverallRiskScore: 40}),
|
||||
)
|
||||
assert.Equal(
|
||||
t,
|
||||
coredata.BusinessImpactHigh,
|
||||
mapVettingBusinessImpact(ThirdPartyInfo{OverallRiskRating: "High"}),
|
||||
)
|
||||
}
|
||||
@@ -10,4 +10,6 @@ Given a third party assessment markdown report, extract the third party informat
|
||||
- Extract only information explicitly present in the report.
|
||||
- Use empty strings for fields not mentioned, empty arrays for missing lists, false for missing booleans.
|
||||
- Never infer or fabricate; if the report does not state something, leave the field empty.
|
||||
- Populate data_locations with countries or regions where data is processed or stored. Prefer ISO 3166-1 alpha-2 codes (US, DE, EU, GLOBAL) when the report states them; otherwise use the country or region names from the report.
|
||||
- Include the headquarters country in data_locations when it is stated in the report.
|
||||
</important>
|
||||
|
||||
@@ -26,6 +26,13 @@ If `research_third_party_externally` is available, use it for incidents, regulat
|
||||
{procedure}
|
||||
</assessment_procedure>
|
||||
|
||||
<persistence>
|
||||
After completing your analysis and writing the report:
|
||||
|
||||
1. Call `save_third_party_info` once with all metadata you discovered (name, description, category, URLs, certifications). Use an empty string for fields you could not find.
|
||||
2. For each sub-processor or vendor dependency discovered, call `link_sub_third_party` with the name, description, category, website URL, country, and purpose. If a third party with the same name already exists it is linked without duplication; otherwise a new one is created with the info you provide.
|
||||
</persistence>
|
||||
|
||||
<important>
|
||||
- Only report information actually discovered through the tools — never fabricate URLs, certifications, or findings.
|
||||
- Note tool failures and inaccessible pages in the report rather than omitting the section.
|
||||
|
||||
@@ -58,7 +58,7 @@ func newSubAgent[T any](
|
||||
tools []agent.Tool,
|
||||
extraOpts ...agent.Option,
|
||||
) (*agent.Agent, error) {
|
||||
outputType, err := agent.NewOutputType[T](spec.outputName)
|
||||
outputType, err := newVettingOutputType[T](spec.outputName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create output type %q: %w", spec.outputName, err)
|
||||
}
|
||||
|
||||
167
pkg/vetting/subprocessors.go
Normal file
167
pkg/vetting/subprocessors.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
const extractSubprocessorsToolName = "extract_subprocessors"
|
||||
|
||||
// subprocessorsFromOrchestratorMessages collects sub-processors from every
|
||||
// extract_subprocessors sub-agent tool result in the orchestrator transcript.
|
||||
// Later tool calls win when the same name appears more than once.
|
||||
func subprocessorsFromOrchestratorMessages(messages []llm.Message) []Subprocessor {
|
||||
toolNames := toolCallNamesByID(messages)
|
||||
|
||||
byName := make(map[string]Subprocessor)
|
||||
order := make([]string, 0)
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role != llm.RoleTool {
|
||||
continue
|
||||
}
|
||||
|
||||
if toolNames[msg.ToolCallID] != extractSubprocessorsToolName {
|
||||
continue
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(msg.Text())
|
||||
if text == "" || !json.Valid([]byte(text)) {
|
||||
continue
|
||||
}
|
||||
|
||||
var output SubprocessorOutput
|
||||
if err := json.Unmarshal([]byte(text), &output); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sub := range output.Subprocessors {
|
||||
if sub.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := normalizeSubprocessorName(sub.Name)
|
||||
if _, exists := byName[key]; !exists {
|
||||
order = append(order, key)
|
||||
}
|
||||
|
||||
byName[key] = sub
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
subs := make([]Subprocessor, 0, len(order))
|
||||
for _, key := range order {
|
||||
subs = append(subs, byName[key])
|
||||
}
|
||||
|
||||
return subs
|
||||
}
|
||||
|
||||
// mergeSubprocessors prefers entries from primary (tool output). Names only
|
||||
// present in secondary (markdown extraction) are appended afterward.
|
||||
func mergeSubprocessors(primary, secondary []Subprocessor) []Subprocessor {
|
||||
if len(primary) == 0 {
|
||||
return secondary
|
||||
}
|
||||
|
||||
if len(secondary) == 0 {
|
||||
return primary
|
||||
}
|
||||
|
||||
merged := make([]Subprocessor, len(primary), len(primary)+len(secondary))
|
||||
copy(merged, primary)
|
||||
|
||||
seen := make(map[string]struct{}, len(primary))
|
||||
for _, sub := range primary {
|
||||
seen[normalizeSubprocessorName(sub.Name)] = struct{}{}
|
||||
}
|
||||
|
||||
for _, sub := range secondary {
|
||||
if sub.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := normalizeSubprocessorName(sub.Name)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[key] = struct{}{}
|
||||
|
||||
merged = append(merged, sub)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func subprocessorListURLFromOrchestratorMessages(messages []llm.Message) string {
|
||||
toolNames := toolCallNamesByID(messages)
|
||||
|
||||
var source string
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role != llm.RoleTool {
|
||||
continue
|
||||
}
|
||||
|
||||
if toolNames[msg.ToolCallID] != extractSubprocessorsToolName {
|
||||
continue
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(msg.Text())
|
||||
if text == "" || !json.Valid([]byte(text)) {
|
||||
continue
|
||||
}
|
||||
|
||||
var output SubprocessorOutput
|
||||
if err := json.Unmarshal([]byte(text), &output); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(output.Source) != "" {
|
||||
source = strings.TrimSpace(output.Source)
|
||||
}
|
||||
}
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
func toolCallNamesByID(messages []llm.Message) map[string]string {
|
||||
toolNames := make(map[string]string)
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role != llm.RoleAssistant {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, tc := range msg.ToolCalls {
|
||||
toolNames[tc.ID] = tc.Function.Name
|
||||
}
|
||||
}
|
||||
|
||||
return toolNames
|
||||
}
|
||||
|
||||
func normalizeSubprocessorName(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
178
pkg/vetting/subprocessors_test.go
Normal file
178
pkg/vetting/subprocessors_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestSubprocessorsFromOrchestratorMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
toolJSON := `{"subprocessors":[{"name":"Amazon Web Services","country":"US","purpose":"Cloud hosting"}],"total_count":1,"source":"https://example.com/subprocessors","is_complete":true}`
|
||||
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleAssistant,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: llm.FunctionCall{
|
||||
Name: extractSubprocessorsToolName,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: "call-1",
|
||||
Parts: []llm.Part{llm.TextPart{Text: toolJSON}},
|
||||
},
|
||||
}
|
||||
|
||||
subs := subprocessorsFromOrchestratorMessages(messages)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]Subprocessor{{
|
||||
Name: "Amazon Web Services",
|
||||
Country: "US",
|
||||
Purpose: "Cloud hosting",
|
||||
}},
|
||||
subs,
|
||||
)
|
||||
}
|
||||
|
||||
func TestSubprocessorsFromOrchestratorMessages_LatestCallWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleAssistant,
|
||||
ToolCalls: []llm.ToolCall{
|
||||
{
|
||||
ID: "call-1",
|
||||
Function: llm.FunctionCall{
|
||||
Name: extractSubprocessorsToolName,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "call-2",
|
||||
Function: llm.FunctionCall{
|
||||
Name: extractSubprocessorsToolName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: "call-1",
|
||||
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Stripe","country":"US","purpose":"Payments"}]}`}},
|
||||
},
|
||||
{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: "call-2",
|
||||
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Stripe","country":"IE","purpose":"Payment processing"}]}`}},
|
||||
},
|
||||
}
|
||||
|
||||
subs := subprocessorsFromOrchestratorMessages(messages)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]Subprocessor{{
|
||||
Name: "Stripe",
|
||||
Country: "IE",
|
||||
Purpose: "Payment processing",
|
||||
}},
|
||||
subs,
|
||||
)
|
||||
}
|
||||
|
||||
func TestSubprocessorsFromOrchestratorMessages_IgnoresOtherTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleAssistant,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: llm.FunctionCall{
|
||||
Name: "assess_security",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: "call-1",
|
||||
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[{"name":"Ignored"}]}`}},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Nil(t, subprocessorsFromOrchestratorMessages(messages))
|
||||
}
|
||||
|
||||
func TestMergeSubprocessors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
toolSubs := []Subprocessor{{
|
||||
Name: "AWS",
|
||||
Country: "US",
|
||||
Purpose: "Hosting",
|
||||
}}
|
||||
extractedSubs := []Subprocessor{
|
||||
{Name: "AWS", Country: "DE", Purpose: "Wrong"},
|
||||
{Name: "SendGrid", Country: "US", Purpose: "Email"},
|
||||
}
|
||||
|
||||
merged := mergeSubprocessors(toolSubs, extractedSubs)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]Subprocessor{
|
||||
{Name: "AWS", Country: "US", Purpose: "Hosting"},
|
||||
{Name: "SendGrid", Country: "US", Purpose: "Email"},
|
||||
},
|
||||
merged,
|
||||
)
|
||||
}
|
||||
|
||||
func TestSubprocessorListURLFromOrchestratorMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleAssistant,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: llm.FunctionCall{
|
||||
Name: extractSubprocessorsToolName,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: "call-1",
|
||||
Parts: []llm.Part{llm.TextPart{Text: `{"subprocessors":[],"source":"https://example.com/legal/subprocessors"}`}},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"https://example.com/legal/subprocessors",
|
||||
subprocessorListURLFromOrchestratorMessages(messages),
|
||||
)
|
||||
}
|
||||
146
pkg/vetting/tools.go
Normal file
146
pkg/vetting/tools.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package vetting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
saveThirdPartyInfoToolParams struct {
|
||||
Name string `json:"name" jsonschema:"Third party display name"`
|
||||
Description string `json:"description" jsonschema:"One-sentence description"`
|
||||
Category string `json:"category" jsonschema:"Category: ANALYTICS, CLOUD_PROVIDER, SECURITY, etc."`
|
||||
HeadquarterAddress string `json:"headquarter_address" jsonschema:"Headquarters city and country"`
|
||||
LegalName string `json:"legal_name" jsonschema:"Legal entity name"`
|
||||
PrivacyPolicyURL string `json:"privacy_policy_url" jsonschema:"Privacy policy URL"`
|
||||
ServiceLevelAgreementURL string `json:"service_level_agreement_url" jsonschema:"SLA URL"`
|
||||
DataProcessingAgreementURL string `json:"data_processing_agreement_url" jsonschema:"DPA URL"`
|
||||
BusinessAssociateAgreementURL string `json:"business_associate_agreement_url" jsonschema:"BAA URL"`
|
||||
SubprocessorsListURL string `json:"subprocessors_list_url" jsonschema:"Subprocessors list URL"`
|
||||
SecurityPageURL string `json:"security_page_url" jsonschema:"Security page URL"`
|
||||
TrustPageURL string `json:"trust_page_url" jsonschema:"Trust center URL"`
|
||||
TermsOfServiceURL string `json:"terms_of_service_url" jsonschema:"Terms of service URL"`
|
||||
StatusPageURL string `json:"status_page_url" jsonschema:"Status page URL"`
|
||||
Certifications []string `json:"certifications" jsonschema:"Compliance certifications found"`
|
||||
}
|
||||
|
||||
saveThirdPartyInfoParams struct {
|
||||
saveThirdPartyInfoToolParams
|
||||
Countries coredata.CountryCodes
|
||||
}
|
||||
|
||||
linkSubThirdPartyParams struct {
|
||||
Name string `json:"name" jsonschema:"Sub-third-party company name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"One-sentence description of what this third party does"`
|
||||
Category string `json:"category,omitempty" jsonschema:"Category: ANALYTICS, CLOUD_PROVIDER, SECURITY, etc."`
|
||||
WebsiteURL string `json:"website_url,omitempty" jsonschema:"Website URL if known"`
|
||||
Country string `json:"country,omitempty" jsonschema:"Country where the sub-third-party operates"`
|
||||
Purpose string `json:"purpose,omitempty" jsonschema:"Purpose or role of this sub-third-party"`
|
||||
}
|
||||
|
||||
// PersistenceContext holds the DB and entity references the tools need.
|
||||
PersistenceContext struct {
|
||||
PG *pg.Client
|
||||
ThirdPartyID gid.GID
|
||||
OrganizationID gid.GID
|
||||
WebsiteURL string
|
||||
}
|
||||
)
|
||||
|
||||
func SaveThirdPartyInfoTool(pc *PersistenceContext) agent.Tool {
|
||||
return vettingFunctionTool(
|
||||
"save_third_party_info",
|
||||
"Persist the discovered third party metadata to the database. Call this once after completing the analysis. Use an empty string for any field you could not discover.",
|
||||
func(ctx context.Context, p saveThirdPartyInfoToolParams) (agent.ToolResult, error) {
|
||||
scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID)
|
||||
|
||||
err := pc.PG.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
thirdParty := &coredata.ThirdParty{}
|
||||
|
||||
if err := thirdParty.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load third party: %w", err)
|
||||
}
|
||||
|
||||
if p.Category != "" {
|
||||
if _, err := parseThirdPartyCategory(p.Category); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
applySaveParams(thirdParty, pc.WebsiteURL, saveThirdPartyInfoParams{
|
||||
saveThirdPartyInfoToolParams: p,
|
||||
})
|
||||
thirdParty.UpdatedAt = time.Now()
|
||||
|
||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot update third party: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, fmt.Errorf("cannot save third party info: %w", err)
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: "Third party info saved successfully."}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func LinkSubThirdPartyTool(pc *PersistenceContext) agent.Tool {
|
||||
return vettingFunctionTool(
|
||||
"link_sub_third_party",
|
||||
"Link a discovered sub-third-party (sub-processor, vendor dependency) to the parent. If a third party with the same name already exists in the organization it is linked as-is; otherwise a new one is created with the provided info. Call once per sub-third-party discovered.",
|
||||
func(ctx context.Context, p linkSubThirdPartyParams) (agent.ToolResult, error) {
|
||||
if p.Name == "" {
|
||||
return agent.ToolResult{Content: "Skipped: empty name."}, nil
|
||||
}
|
||||
|
||||
scope := coredata.NewScopeFromObjectID(pc.ThirdPartyID)
|
||||
|
||||
err := pc.PG.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
return linkSubThirdParty(ctx, conn, scope, pc, p)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, fmt.Errorf("cannot link sub third party: %w", err)
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: fmt.Sprintf("Linked %q as sub third party.", p.Name)}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func parseThirdPartyCategory(raw string) (coredata.ThirdPartyCategory, error) {
|
||||
category := coredata.ThirdPartyCategory(raw)
|
||||
if !category.IsValid() {
|
||||
return "", fmt.Errorf("invalid third party category %q", raw)
|
||||
}
|
||||
|
||||
return category, nil
|
||||
}
|
||||
Reference in New Issue
Block a user