fix(config): validate entity_labels structure on PATCH (#902)

* test: add regression tests for #874 and #894

Add tests for None event_date in fact extraction (AttributeError fix)
and for _register_profile skipping .env overwrite with short config keys.

* fix(config): validate entity_labels structure on PATCH (#891)

Config PATCH accepted bare strings in entity_labels values without
validation, causing silent failures at retain time. Now validates
via parse_entity_labels() before writing to DB, and fixes the
BankTemplateConfig type from list[str] to list[dict[str, Any]].

* fix(scripts): handle Python client generator README crash gracefully

The openapi-generator sometimes crashes writing README_onlypackage.mustache.
Allow the failure with || true since all API/model files are generated
before that step, and add a verification check for api_client.py.

* chore: regenerate docs skill openapi.json
This commit is contained in:
Nicolò Boschi 2026-04-07 11:58:02 +02:00 committed by GitHub
parent f659bb17c4
commit 7e23f8e149
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 37 additions and 13 deletions

View file

@ -1661,7 +1661,9 @@ class BankTemplateConfig(BaseModel):
disposition_skepticism: int | None = Field(default=None, ge=1, le=5, description="Skepticism trait (1-5)") disposition_skepticism: int | None = Field(default=None, ge=1, le=5, description="Skepticism trait (1-5)")
disposition_literalism: int | None = Field(default=None, ge=1, le=5, description="Literalism trait (1-5)") disposition_literalism: int | None = Field(default=None, ge=1, le=5, description="Literalism trait (1-5)")
disposition_empathy: int | None = Field(default=None, ge=1, le=5, description="Empathy trait (1-5)") disposition_empathy: int | None = Field(default=None, ge=1, le=5, description="Empathy trait (1-5)")
entity_labels: list[str] | None = Field(default=None, description="Controlled vocabulary for entity labels") entity_labels: list[dict[str, Any]] | None = Field(
default=None, description="Controlled vocabulary for entity labels"
)
entities_allow_free_form: bool | None = Field( entities_allow_free_form: bool | None = Field(
default=None, description="Allow entities outside the label vocabulary" default=None, description="Allow entities outside the label vocabulary"
) )

View file

@ -239,6 +239,15 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}") logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility) # Continue without permission check (fail open for backward compatibility)
# Validate entity_labels structure
if "entity_labels" in normalized_updates and normalized_updates["entity_labels"] is not None:
from .engine.retain.entity_labels import parse_entity_labels
try:
parse_entity_labels(normalized_updates["entity_labels"])
except Exception as e:
raise ValueError(f"Invalid entity_labels format: {e}")
# Validate retain_strategies: reject empty string keys # Validate retain_strategies: reject empty string keys
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]: if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()] empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]

View file

@ -3570,7 +3570,7 @@ components:
type: integer type: integer
entity_labels: entity_labels:
items: items:
type: string additionalProperties: {}
nullable: true nullable: true
type: array type: array
entities_allow_free_form: entities_allow_free_form:

View file

@ -29,7 +29,7 @@ type BankTemplateConfig struct {
DispositionSkepticism NullableInt32 `json:"disposition_skepticism,omitempty"` DispositionSkepticism NullableInt32 `json:"disposition_skepticism,omitempty"`
DispositionLiteralism NullableInt32 `json:"disposition_literalism,omitempty"` DispositionLiteralism NullableInt32 `json:"disposition_literalism,omitempty"`
DispositionEmpathy NullableInt32 `json:"disposition_empathy,omitempty"` DispositionEmpathy NullableInt32 `json:"disposition_empathy,omitempty"`
EntityLabels []string `json:"entity_labels,omitempty"` EntityLabels []map[string]interface{} `json:"entity_labels,omitempty"`
EntitiesAllowFreeForm NullableBool `json:"entities_allow_free_form,omitempty"` EntitiesAllowFreeForm NullableBool `json:"entities_allow_free_form,omitempty"`
} }
@ -471,9 +471,9 @@ func (o *BankTemplateConfig) UnsetDispositionEmpathy() {
} }
// GetEntityLabels returns the EntityLabels field value if set, zero value otherwise (both if not set or set to explicit null). // GetEntityLabels returns the EntityLabels field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetEntityLabels() []string { func (o *BankTemplateConfig) GetEntityLabels() []map[string]interface{} {
if o == nil { if o == nil {
var ret []string var ret []map[string]interface{}
return ret return ret
} }
return o.EntityLabels return o.EntityLabels
@ -482,7 +482,7 @@ func (o *BankTemplateConfig) GetEntityLabels() []string {
// GetEntityLabelsOk returns a tuple with the EntityLabels field value if set, nil otherwise // GetEntityLabelsOk returns a tuple with the EntityLabels field value if set, nil otherwise
// and a boolean to check if the value has been set. // and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned // NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetEntityLabelsOk() ([]string, bool) { func (o *BankTemplateConfig) GetEntityLabelsOk() ([]map[string]interface{}, bool) {
if o == nil || IsNil(o.EntityLabels) { if o == nil || IsNil(o.EntityLabels) {
return nil, false return nil, false
} }
@ -498,8 +498,8 @@ func (o *BankTemplateConfig) HasEntityLabels() bool {
return false return false
} }
// SetEntityLabels gets a reference to the given []string and assigns it to the EntityLabels field. // SetEntityLabels gets a reference to the given []map[string]interface{} and assigns it to the EntityLabels field.
func (o *BankTemplateConfig) SetEntityLabels(v []string) { func (o *BankTemplateConfig) SetEntityLabels(v []map[string]interface{}) {
o.EntityLabels = v o.EntityLabels = v
} }

View file

@ -37,7 +37,7 @@ class BankTemplateConfig(BaseModel):
disposition_skepticism: Optional[Annotated[int, Field(le=5, strict=True, ge=1)]] = None disposition_skepticism: Optional[Annotated[int, Field(le=5, strict=True, ge=1)]] = None
disposition_literalism: Optional[Annotated[int, Field(le=5, strict=True, ge=1)]] = None disposition_literalism: Optional[Annotated[int, Field(le=5, strict=True, ge=1)]] = None
disposition_empathy: Optional[Annotated[int, Field(le=5, strict=True, ge=1)]] = None disposition_empathy: Optional[Annotated[int, Field(le=5, strict=True, ge=1)]] = None
entity_labels: Optional[List[StrictStr]] = None entity_labels: Optional[List[Dict[str, Any]]] = None
entities_allow_free_form: Optional[StrictBool] = None entities_allow_free_form: Optional[StrictBool] = None
__properties: ClassVar[List[str]] = ["reflect_mission", "retain_mission", "retain_extraction_mode", "retain_custom_instructions", "retain_chunk_size", "enable_observations", "observations_mission", "disposition_skepticism", "disposition_literalism", "disposition_empathy", "entity_labels", "entities_allow_free_form"] __properties: ClassVar[List[str]] = ["reflect_mission", "retain_mission", "retain_extraction_mode", "retain_custom_instructions", "retain_chunk_size", "enable_observations", "observations_mission", "disposition_skepticism", "disposition_literalism", "disposition_empathy", "entity_labels", "entities_allow_free_form"]

View file

@ -459,7 +459,9 @@ export type BankTemplateConfig = {
* *
* Controlled vocabulary for entity labels * Controlled vocabulary for entity labels
*/ */
entity_labels?: Array<string> | null; entity_labels?: Array<{
[key: string]: unknown;
}> | null;
/** /**
* Entities Allow Free Form * Entities Allow Free Form
* *

View file

@ -5275,7 +5275,8 @@
"anyOf": [ "anyOf": [
{ {
"items": { "items": {
"type": "string" "additionalProperties": true,
"type": "object"
}, },
"type": "array" "type": "array"
}, },

View file

@ -108,6 +108,9 @@ cd "$PYTHON_CLIENT_DIR"
# Run openapi-generator via Docker (pinned version for reproducibility) # Run openapi-generator via Docker (pinned version for reproducibility)
# Use --platform linux/amd64 to ensure identical output on both macOS (arm64) and Linux CI (amd64) # Use --platform linux/amd64 to ensure identical output on both macOS (arm64) and Linux CI (amd64)
# Use --user to match current user's UID/GID so generated files are writable # Use --user to match current user's UID/GID so generated files are writable
# Note: the generator may exit non-zero due to a known bug writing
# README_onlypackage.mustache, but all API/model files are generated
# before that step, so we allow the failure and verify files below.
docker run --rm \ docker run --rm \
--platform linux/amd64 \ --platform linux/amd64 \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
@ -118,7 +121,13 @@ docker run --rm \
-i /local/openapi.json \ -i /local/openapi.json \
-g python \ -g python \
-o /local/out \ -o /local/out \
-c /local/config.yaml -c /local/config.yaml || true
# Verify critical generated files exist
if [ ! -f "$PYTHON_CLIENT_DIR/hindsight_client_api/api_client.py" ]; then
echo "❌ Error: Python client generation failed - api_client.py not found"
exit 1
fi
echo "Organizing generated files..." echo "Organizing generated files..."

View file

@ -5275,7 +5275,8 @@
"anyOf": [ "anyOf": [
{ {
"items": { "items": {
"type": "string" "additionalProperties": true,
"type": "object"
}, },
"type": "array" "type": "array"
}, },