feat: simplify SOP to immediate-effect configuration
This commit is contained in:
11
Dockerfile
11
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine AS web-builder
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /src/web
|
WORKDIR /src/web
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY web/package.json web/package-lock.json ./
|
||||||
@@ -6,6 +6,12 @@ RUN npm ci
|
|||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
WORKDIR /src/sdk
|
||||||
|
COPY sdk/package.json sdk/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY sdk/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.24-alpine AS go-builder
|
FROM golang:1.24-alpine AS go-builder
|
||||||
|
|
||||||
ARG GOPROXY=https://goproxy.cn,direct
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
@@ -13,7 +19,8 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN GOPROXY=${GOPROXY} go mod download
|
RUN GOPROXY=${GOPROXY} go mod download
|
||||||
COPY . ./
|
COPY . ./
|
||||||
COPY --from=web-builder /src/web/dist ./web/dist
|
COPY --from=frontend-builder /src/web/dist ./web/dist
|
||||||
|
COPY --from=frontend-builder /src/sdk/dist ./sdk/dist
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/iqudo-top1 .
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/iqudo-top1 .
|
||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -1,6 +1,7 @@
|
|||||||
WEB_DIR := web
|
WEB_DIR := web
|
||||||
|
SDK_DIR := sdk
|
||||||
|
|
||||||
.PHONY: web-install web-build build test vet check seed-pet-doctor docker-config docker-up docker-down docker-logs
|
.PHONY: web-install web-build sdk-install sdk-build build test vet check seed-pet-doctor docker-config docker-up docker-down docker-logs
|
||||||
|
|
||||||
web-install:
|
web-install:
|
||||||
cd $(WEB_DIR) && npm install
|
cd $(WEB_DIR) && npm install
|
||||||
@@ -8,7 +9,13 @@ web-install:
|
|||||||
web-build:
|
web-build:
|
||||||
cd $(WEB_DIR) && npm run build
|
cd $(WEB_DIR) && npm run build
|
||||||
|
|
||||||
build: web-build
|
sdk-install:
|
||||||
|
cd $(SDK_DIR) && npm install
|
||||||
|
|
||||||
|
sdk-build:
|
||||||
|
cd $(SDK_DIR) && npm run build
|
||||||
|
|
||||||
|
build: web-build sdk-build
|
||||||
go build -o bin/iqudo-top1 .
|
go build -o bin/iqudo-top1 .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ make seed-pet-doctor
|
|||||||
该命令会幂等地创建或完善以下配置:
|
该命令会幂等地创建或完善以下配置:
|
||||||
|
|
||||||
- 宠物医生问诊问药场景
|
- 宠物医生问诊问药场景
|
||||||
- 47 个动态问诊字段,覆盖咨询背景、宠物档案、主诉、生命状态、既往史和用药详情
|
- 53 个场景输入字段,覆盖订单、商品图片、商品症状标签、客户、宠物、主诉、生命状态、既往史和用药详情
|
||||||
- 线上问诊边界、急症红旗、用药安全、观察与复诊 4 张知识卡
|
- 线上问诊边界、急症红旗、用药安全、观察与复诊 4 个场景知识实体
|
||||||
- 包含普通问诊、急症转诊、常规用药咨询和用药风险转诊分支的已发布 SOP
|
- 包含宠物信息收集、症状与疾病确认、方案与商品推荐的可用 SOP
|
||||||
|
|
||||||
示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。
|
示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。
|
||||||
|
|||||||
@@ -24,3 +24,23 @@ seed:
|
|||||||
admin_username: admin
|
admin_username: admin
|
||||||
admin_password: ""
|
admin_password: ""
|
||||||
admin_display_name: 平台管理员
|
admin_display_name: 平台管理员
|
||||||
|
multitable:
|
||||||
|
enabled: false
|
||||||
|
base_url: https://table.iwork-ai.com/open/v1
|
||||||
|
api_key: ""
|
||||||
|
sync_interval: 5s
|
||||||
|
request_timeout: 10s
|
||||||
|
batch_size: 20
|
||||||
|
max_attempts: 12
|
||||||
|
tables:
|
||||||
|
scenarios: 0
|
||||||
|
scenario_fields: 0
|
||||||
|
scenario_rules: 0
|
||||||
|
sop_versions: 0
|
||||||
|
sop_nodes: 0
|
||||||
|
sop_edges: 0
|
||||||
|
knowledge_items: 0
|
||||||
|
knowledge_relations: 0
|
||||||
|
knowledge_versions: 0
|
||||||
|
runs: 0
|
||||||
|
feedback: 0
|
||||||
|
|||||||
@@ -24,3 +24,23 @@ seed:
|
|||||||
admin_username: admin
|
admin_username: admin
|
||||||
admin_password: admin123
|
admin_password: admin123
|
||||||
admin_display_name: 平台管理员
|
admin_display_name: 平台管理员
|
||||||
|
multitable:
|
||||||
|
enabled: false
|
||||||
|
base_url: https://table.iwork-ai.com/open/v1
|
||||||
|
api_key: ""
|
||||||
|
sync_interval: 5s
|
||||||
|
request_timeout: 10s
|
||||||
|
batch_size: 20
|
||||||
|
max_attempts: 12
|
||||||
|
tables:
|
||||||
|
scenarios: 0
|
||||||
|
scenario_fields: 0
|
||||||
|
scenario_rules: 0
|
||||||
|
sop_versions: 0
|
||||||
|
sop_nodes: 0
|
||||||
|
sop_edges: 0
|
||||||
|
knowledge_items: 0
|
||||||
|
knowledge_relations: 0
|
||||||
|
knowledge_versions: 0
|
||||||
|
runs: 0
|
||||||
|
feedback: 0
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/ilyakaznacheev/cleanenv v1.5.0
|
github.com/ilyakaznacheev/cleanenv v1.5.0
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.36.0
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/datatypes v1.2.7
|
||||||
@@ -40,9 +41,15 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
go.uber.org/atomic v1.7.0 // indirect
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
|||||||
17
go.sum
17
go.sum
@@ -113,6 +113,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -125,6 +127,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
||||||
|
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -135,12 +141,19 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
|
||||||
|
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
@@ -162,6 +175,8 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
|||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package audit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
@@ -11,10 +13,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Record(db *gorm.DB, principal auth.Principal, action, resource string, resourceID uint64, payload interface{}) error {
|
func Record(db *gorm.DB, principal auth.Principal, action, resource string, resourceID uint64, payload interface{}) error {
|
||||||
data, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fields := []zap.Field{
|
fields := []zap.Field{
|
||||||
zap.Uint64("tenant_id", principal.TenantID),
|
zap.Uint64("tenant_id", principal.TenantID),
|
||||||
zap.Uint64("user_id", principal.UserID),
|
zap.Uint64("user_id", principal.UserID),
|
||||||
@@ -22,7 +20,9 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
|
|||||||
zap.String("resource", resource),
|
zap.String("resource", resource),
|
||||||
zap.Uint64("resource_id", resourceID),
|
zap.Uint64("resource_id", resourceID),
|
||||||
}
|
}
|
||||||
if err := db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error; err != nil {
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return RecordTx(tx, principal, action, resource, resourceID, payload)
|
||||||
|
}); err != nil {
|
||||||
zap.L().Error("persist business event", append(fields, zap.Error(err))...)
|
zap.L().Error("persist business event", append(fields, zap.Error(err))...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,43 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordTx persists the audit entry and its projection event in the caller's
|
||||||
|
// transaction. Callers that mutate business data should use this function so
|
||||||
|
// the mutation and Outbox event cannot diverge.
|
||||||
|
func RecordTx(tx *gorm.DB, principal auth.Principal, action, resource string, resourceID uint64, payload interface{}) error {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry := model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}
|
||||||
|
if err := tx.Create(&entry).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isProjected(resource, action) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
auditID := entry.ID
|
||||||
|
outbox := model.MultiTableOutbox{TenantID: principal.TenantID, AuditLogID: &auditID, Resource: resource, ResourceID: resourceID, Action: action, Payload: datatypes.JSON(data), DedupeKey: fmt.Sprintf("audit:%d", entry.ID), Status: "pending", AvailableAt: time.Now()}
|
||||||
|
return tx.Create(&outbox).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProjected(resource, action string) bool {
|
||||||
|
switch resource {
|
||||||
|
case "scenario":
|
||||||
|
return action == "create" || action == "update" || action == "archive"
|
||||||
|
case "scenario_field":
|
||||||
|
return action == "create" || action == "update" || action == "delete"
|
||||||
|
case "sop":
|
||||||
|
return action == "publish" || action == "offline"
|
||||||
|
case "scenario_rule", "knowledge_item", "knowledge_relation":
|
||||||
|
return action == "create" || action == "update" || action == "delete" || action == "archive"
|
||||||
|
case "sop_run":
|
||||||
|
return action == "start" || action == "finish" || action == "feedback"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func logBusinessEvent(fields []zap.Field) {
|
func logBusinessEvent(fields []zap.Field) {
|
||||||
zap.L().Info("business event", fields...)
|
zap.L().Info("business event", fields...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,8 +181,7 @@ func Seed(db *gorm.DB, cfg config.SeedConfig) error {
|
|||||||
Permissions []string
|
Permissions []string
|
||||||
}{
|
}{
|
||||||
{Name: "管理员", Code: "admin", Permissions: []string{"*"}},
|
{Name: "管理员", Code: "admin", Permissions: []string{"*"}},
|
||||||
{Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "sop.submit_review", "knowledge.view", "knowledge.edit", "runs.view_all"}},
|
{Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "knowledge.view", "knowledge.edit", "runs.view_all"}},
|
||||||
{Name: "审核者", Code: "reviewer", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "sop.view", "sop.review", "sop.publish", "knowledge.view", "runs.view_all"}},
|
|
||||||
{Name: "一线执行者", Code: "operator", Permissions: []string{"dashboard.view", "scenario.view", "sop.execute", "knowledge.view", "runs.view_own", "runs.feedback"}},
|
{Name: "一线执行者", Code: "operator", Permissions: []string{"dashboard.view", "scenario.view", "sop.execute", "knowledge.view", "runs.view_own", "runs.feedback"}},
|
||||||
}
|
}
|
||||||
roles := make(map[string]model.Role, len(roleDefinitions))
|
roles := make(map[string]model.Role, len(roleDefinitions))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Config struct {
|
|||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
Seed SeedConfig `yaml:"seed"`
|
Seed SeedConfig `yaml:"seed"`
|
||||||
|
MultiTable MultiTableConfig `yaml:"multitable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
@@ -63,6 +64,32 @@ type SeedConfig struct {
|
|||||||
AdminDisplayName string `yaml:"admin_display_name" env:"APP_SEED_ADMIN_DISPLAY_NAME"`
|
AdminDisplayName string `yaml:"admin_display_name" env:"APP_SEED_ADMIN_DISPLAY_NAME"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MultiTableConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled" env:"APP_MULTITABLE_ENABLED" env-default:"false"`
|
||||||
|
BaseURL string `yaml:"base_url" env:"APP_MULTITABLE_BASE_URL" env-default:"https://table.iwork-ai.com/open/v1"`
|
||||||
|
APIKey string `yaml:"api_key" env:"APP_MULTITABLE_API_KEY"`
|
||||||
|
SyncInterval time.Duration `yaml:"sync_interval" env:"APP_MULTITABLE_SYNC_INTERVAL" env-default:"5s"`
|
||||||
|
RequestTimeout time.Duration `yaml:"request_timeout" env:"APP_MULTITABLE_REQUEST_TIMEOUT" env-default:"10s"`
|
||||||
|
BatchSize int `yaml:"batch_size" env:"APP_MULTITABLE_BATCH_SIZE" env-default:"20"`
|
||||||
|
MaxAttempts int `yaml:"max_attempts" env:"APP_MULTITABLE_MAX_ATTEMPTS" env-default:"12"`
|
||||||
|
Tables MultiTableTables `yaml:"tables"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultiTableTables struct {
|
||||||
|
Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"`
|
||||||
|
ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"`
|
||||||
|
ScenarioRules uint64 `yaml:"scenario_rules" env:"APP_MULTITABLE_TABLES_SCENARIO_RULES"`
|
||||||
|
SOPVersions uint64 `yaml:"sop_versions" env:"APP_MULTITABLE_TABLES_SOP_VERSIONS"`
|
||||||
|
SOPNodes uint64 `yaml:"sop_nodes" env:"APP_MULTITABLE_TABLES_SOP_NODES"`
|
||||||
|
SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"`
|
||||||
|
KnowledgeItems uint64 `yaml:"knowledge_items" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS"`
|
||||||
|
KnowledgeRelations uint64 `yaml:"knowledge_relations" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS"`
|
||||||
|
// KnowledgeVersions is retained only for replaying historical projection events.
|
||||||
|
KnowledgeVersions uint64 `yaml:"knowledge_versions" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS"`
|
||||||
|
Runs uint64 `yaml:"runs" env:"APP_MULTITABLE_TABLES_RUNS"`
|
||||||
|
Feedback uint64 `yaml:"feedback" env:"APP_MULTITABLE_TABLES_FEEDBACK"`
|
||||||
|
}
|
||||||
|
|
||||||
type LoadOptions struct {
|
type LoadOptions struct {
|
||||||
Environment string
|
Environment string
|
||||||
ConfigDir string
|
ConfigDir string
|
||||||
@@ -138,5 +165,33 @@ func (c Config) Validate() error {
|
|||||||
if c.App.Env == "prod" && c.Database.Password == "" {
|
if c.App.Env == "prod" && c.Database.Password == "" {
|
||||||
return errors.New("database password is required in production")
|
return errors.New("database password is required in production")
|
||||||
}
|
}
|
||||||
|
if c.MultiTable.Enabled {
|
||||||
|
if !strings.HasPrefix(c.MultiTable.BaseURL, "https://") {
|
||||||
|
return errors.New("multitable.base_url must use https when multitable is enabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.MultiTable.APIKey) == "" {
|
||||||
|
return errors.New("APP_MULTITABLE_API_KEY is required when multitable is enabled")
|
||||||
|
}
|
||||||
|
if c.MultiTable.SyncInterval <= 0 || c.MultiTable.RequestTimeout <= 0 || c.MultiTable.BatchSize < 1 || c.MultiTable.MaxAttempts < 1 {
|
||||||
|
return errors.New("multitable retry and timeout settings must be positive")
|
||||||
|
}
|
||||||
|
ids := []uint64{
|
||||||
|
c.MultiTable.Tables.Scenarios,
|
||||||
|
c.MultiTable.Tables.ScenarioFields,
|
||||||
|
c.MultiTable.Tables.ScenarioRules,
|
||||||
|
c.MultiTable.Tables.SOPVersions,
|
||||||
|
c.MultiTable.Tables.SOPNodes,
|
||||||
|
c.MultiTable.Tables.SOPEdges,
|
||||||
|
c.MultiTable.Tables.KnowledgeItems,
|
||||||
|
c.MultiTable.Tables.KnowledgeRelations,
|
||||||
|
c.MultiTable.Tables.Runs,
|
||||||
|
c.MultiTable.Tables.Feedback,
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == 0 {
|
||||||
|
return errors.New("all multitable table IDs are required when multitable is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,34 @@ func TestLoadProductionRequiresEnvironmentSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadMultiTableEnvironmentConfiguration(t *testing.T) {
|
||||||
|
dir := writeConfigs(t, "")
|
||||||
|
t.Setenv("APP_MULTITABLE_ENABLED", "true")
|
||||||
|
t.Setenv("APP_MULTITABLE_API_KEY", "test-key")
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"APP_MULTITABLE_TABLES_SCENARIOS": "46",
|
||||||
|
"APP_MULTITABLE_TABLES_SCENARIO_FIELDS": "47",
|
||||||
|
"APP_MULTITABLE_TABLES_SCENARIO_RULES": "54",
|
||||||
|
"APP_MULTITABLE_TABLES_SOP_VERSIONS": "48",
|
||||||
|
"APP_MULTITABLE_TABLES_SOP_NODES": "49",
|
||||||
|
"APP_MULTITABLE_TABLES_SOP_EDGES": "50",
|
||||||
|
"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS": "51",
|
||||||
|
"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS": "55",
|
||||||
|
"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS": "56",
|
||||||
|
"APP_MULTITABLE_TABLES_RUNS": "52",
|
||||||
|
"APP_MULTITABLE_TABLES_FEEDBACK": "53",
|
||||||
|
} {
|
||||||
|
t.Setenv(key, value)
|
||||||
|
}
|
||||||
|
cfg, err := Load(LoadOptions{ConfigDir: dir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.MultiTable.Enabled || cfg.MultiTable.Tables.Scenarios != 46 || cfg.MultiTable.Tables.ScenarioRules != 54 || cfg.MultiTable.Tables.KnowledgeItems != 55 || cfg.MultiTable.Tables.KnowledgeRelations != 56 || cfg.MultiTable.Tables.Feedback != 53 {
|
||||||
|
t.Fatalf("unexpected multitable configuration: %+v", cfg.MultiTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeConfigs(t *testing.T, profile string) string {
|
func writeConfigs(t *testing.T, profile string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
@@ -20,13 +20,17 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger, environment string) http.Handler {
|
func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, sdkFS fs.FS, log *zap.Logger, environment string) http.Handler {
|
||||||
if environment == "prod" {
|
if environment == "prod" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
|
router.Use(middleware.CORS())
|
||||||
router.Use(middleware.RequestLogger(log.Named("http")), middleware.Recovery(log.Named("recovery")))
|
router.Use(middleware.RequestLogger(log.Named("http")), middleware.Recovery(log.Named("recovery")))
|
||||||
|
|
||||||
|
// 通过 HTTP 直接提供 SDK 产物,例如 /sdk/index.js 与 /sdk/index.iife.js。
|
||||||
|
router.StaticFS("/sdk", http.FS(sdkFS))
|
||||||
|
|
||||||
authHandler := auth.NewHandler(authService)
|
authHandler := auth.NewHandler(authService)
|
||||||
scenarioHandler := scenario.NewHandler(db)
|
scenarioHandler := scenario.NewHandler(db)
|
||||||
sopHandler := sop.NewHandler(db)
|
sopHandler := sop.NewHandler(db)
|
||||||
@@ -41,6 +45,13 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
|||||||
})
|
})
|
||||||
api.POST("/auth/login", authHandler.Login)
|
api.POST("/auth/login", authHandler.Login)
|
||||||
api.POST("/auth/refresh", authHandler.Refresh)
|
api.POST("/auth/refresh", authHandler.Refresh)
|
||||||
|
public := router.Group("/public")
|
||||||
|
public.POST("/scenarios/:scenarioKey/runs", runHandler.PublicStart)
|
||||||
|
public.GET("/runs/:id/current", runHandler.PublicCurrent)
|
||||||
|
public.POST("/runs/:id/submit", runHandler.PublicSubmit)
|
||||||
|
public.POST("/runs/:id/next", runHandler.PublicNext)
|
||||||
|
public.POST("/runs/:id/finish", runHandler.PublicFinish)
|
||||||
|
public.POST("/runs/:id/reset", runHandler.PublicReset)
|
||||||
|
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
protected.Use(middleware.Authenticate(authService))
|
protected.Use(middleware.Authenticate(authService))
|
||||||
@@ -58,23 +69,18 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
|||||||
protected.POST("/scenarios/:id/fields", middleware.RequirePermission("scenario.edit"), scenarioHandler.CreateField)
|
protected.POST("/scenarios/:id/fields", middleware.RequirePermission("scenario.edit"), scenarioHandler.CreateField)
|
||||||
protected.PUT("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.UpdateField)
|
protected.PUT("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.UpdateField)
|
||||||
protected.DELETE("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.DeleteField)
|
protected.DELETE("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.DeleteField)
|
||||||
|
protected.GET("/scenarios/:id/contract", middleware.RequirePermission("scenario.view"), scenarioHandler.GetContract)
|
||||||
|
protected.PUT("/scenarios/:id/contract", middleware.RequirePermission("scenario.edit"), scenarioHandler.ReplaceContract)
|
||||||
|
protected.POST("/scenarios/:id/preview", middleware.RequirePermission("scenario.view"), runHandler.PreviewScenario)
|
||||||
|
|
||||||
protected.GET("/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
protected.GET("/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
||||||
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
||||||
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
|
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
|
||||||
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
|
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
|
||||||
protected.GET("/sops/:id/versions", middleware.RequirePermission("sop.view"), sopHandler.ListVersions)
|
protected.PUT("/sops/:id/graph", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
|
||||||
protected.PUT("/sops/:id/draft", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
|
|
||||||
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate)
|
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate)
|
||||||
protected.POST("/sops/:id/submit-review", middleware.RequirePermission("sop.submit_review"), sopHandler.SubmitReview)
|
|
||||||
protected.POST("/sops/:id/publish", middleware.RequirePermission("sop.publish"), sopHandler.Publish)
|
|
||||||
protected.POST("/sops/:id/reject", middleware.RequirePermission("sop.review"), sopHandler.Reject)
|
|
||||||
protected.POST("/sops/:id/offline", middleware.RequirePermission("sop.publish"), sopHandler.Offline)
|
|
||||||
protected.POST("/sops/:id/versions", middleware.RequirePermission("sop.edit"), sopHandler.CreateVersion)
|
|
||||||
protected.POST("/sops/:id/rollback", middleware.RequirePermission("sop.publish"), sopHandler.Rollback)
|
|
||||||
protected.GET("/reviews", middleware.RequirePermission("sop.review"), sopHandler.Reviews)
|
|
||||||
|
|
||||||
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
|
protected.GET("/available-sops", middleware.RequirePermission("sop.execute"), runHandler.AvailableSOPs)
|
||||||
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
|
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
|
||||||
protected.GET("/runs/options", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Options)
|
protected.GET("/runs/options", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Options)
|
||||||
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start)
|
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start)
|
||||||
@@ -84,11 +90,8 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
|||||||
protected.POST("/runs/:id/finish", middleware.RequirePermission("sop.execute"), runHandler.Finish)
|
protected.POST("/runs/:id/finish", middleware.RequirePermission("sop.execute"), runHandler.Finish)
|
||||||
protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback)
|
protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback)
|
||||||
|
|
||||||
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List)
|
protected.GET("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.view"), knowledgeHandler.GetGraph)
|
||||||
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create)
|
protected.PUT("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.ReplaceGraph)
|
||||||
protected.GET("/knowledge-cards/:id/versions", middleware.RequirePermission("knowledge.view"), knowledgeHandler.Versions)
|
|
||||||
protected.PUT("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Update)
|
|
||||||
protected.DELETE("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Delete)
|
|
||||||
|
|
||||||
router.NoRoute(spaHandler(frontend))
|
router.NoRoute(spaHandler(frontend))
|
||||||
return router
|
return router
|
||||||
|
|||||||
44
internal/httpserver/server_test.go
Normal file
44
internal/httpserver/server_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSDKFilesAreServedOverHTTP(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
frontend := fstest.MapFS{
|
||||||
|
"index.html": &fstest.MapFile{Data: []byte("<html>ok</html>")},
|
||||||
|
}
|
||||||
|
sdkFS := fstest.MapFS{
|
||||||
|
"index.js": &fstest.MapFile{Data: []byte("export function create() {}")},
|
||||||
|
"index.iife.js": &fstest.MapFile{Data: []byte("var IqudooSalesScenario = {};")},
|
||||||
|
}
|
||||||
|
handler := New(nil, auth.NewService(nil, config.AuthConfig{}), frontend, sdkFS, zap.NewNop(), "test")
|
||||||
|
|
||||||
|
for _, path := range []string{"/sdk/index.js", "/sdk/index.iife.js"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s status = %d, want %d", path, rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||||
|
t.Fatalf("GET %s missing open CORS header", path)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" {
|
||||||
|
t.Fatalf("GET %s Content-Type = %q, want text/javascript", path, got)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() == 0 {
|
||||||
|
t.Fatalf("GET %s returned empty body", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
internal/knowledge/content.go
Normal file
197
internal/knowledge/content.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var contentKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,63}$`)
|
||||||
|
var placeholderPattern = regexp.MustCompile(`\{\{\s*((?:input|card)\.[A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
|
||||||
|
|
||||||
|
type CardField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
SourceField string `json:"source_field,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CopyTemplate struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Content struct {
|
||||||
|
Fields []CardField `json:"fields,omitempty"`
|
||||||
|
CopyTemplates []CopyTemplate `json:"copy_templates,omitempty"`
|
||||||
|
StandardCopy string `json:"standard_copy,omitempty"`
|
||||||
|
ForbiddenCopy string `json:"forbidden_copy,omitempty"`
|
||||||
|
RiskNote string `json:"risk_note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderedCopy struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseContent(raw []byte) (Content, error) {
|
||||||
|
var content Content
|
||||||
|
if err := json.Unmarshal(raw, &content); err != nil {
|
||||||
|
return Content{}, errors.New("知识卡内容必须是 JSON 对象")
|
||||||
|
}
|
||||||
|
return normalizeAndValidate(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseContentMap(value map[string]interface{}) (Content, error) {
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return Content{}, err
|
||||||
|
}
|
||||||
|
return ParseContent(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateReferences(content Content, scenarioFieldKeys map[string]bool) error {
|
||||||
|
cardFieldKeys := make(map[string]bool, len(content.Fields))
|
||||||
|
for _, field := range content.Fields {
|
||||||
|
cardFieldKeys[field.Key] = true
|
||||||
|
if field.SourceField != "" && !scenarioFieldKeys[field.SourceField] {
|
||||||
|
return fmt.Errorf("知识字段“%s”关联的场景字段“%s”不存在", field.Name, field.SourceField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, template := range content.CopyTemplates {
|
||||||
|
for _, match := range placeholderPattern.FindAllStringSubmatch(template.Content, -1) {
|
||||||
|
if len(match) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(match[1], ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "input":
|
||||||
|
if !scenarioFieldKeys[parts[1]] {
|
||||||
|
return fmt.Errorf("话术“%s”引用的场景字段“%s”不存在", template.Title, parts[1])
|
||||||
|
}
|
||||||
|
case "card":
|
||||||
|
if !cardFieldKeys[parts[1]] {
|
||||||
|
return fmt.Errorf("话术“%s”引用的知识字段“%s”不存在", template.Title, parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(content Content, answers map[string]interface{}) []RenderedCopy {
|
||||||
|
values := make(map[string]string, len(answers)+len(content.Fields)*2)
|
||||||
|
for key, value := range answers {
|
||||||
|
values["input."+key] = displayValue(value)
|
||||||
|
}
|
||||||
|
for _, field := range content.Fields {
|
||||||
|
value := field.Value
|
||||||
|
if field.SourceField != "" {
|
||||||
|
if answer, ok := answers[field.SourceField]; ok && hasValue(answer) {
|
||||||
|
value = answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values["card."+field.Key] = displayValue(value)
|
||||||
|
}
|
||||||
|
copies := make([]RenderedCopy, 0, len(content.CopyTemplates))
|
||||||
|
for _, template := range content.CopyTemplates {
|
||||||
|
copies = append(copies, RenderedCopy{ID: template.ID, Title: template.Title, Content: placeholderPattern.ReplaceAllStringFunc(template.Content, func(token string) string {
|
||||||
|
matches := placeholderPattern.FindStringSubmatch(token)
|
||||||
|
if len(matches) != 2 || values[matches[1]] == "" {
|
||||||
|
return "未提供"
|
||||||
|
}
|
||||||
|
return values[matches[1]]
|
||||||
|
})})
|
||||||
|
}
|
||||||
|
return copies
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrimaryCopy(content Content) string {
|
||||||
|
if len(content.CopyTemplates) > 0 {
|
||||||
|
return content.CopyTemplates[0].Content
|
||||||
|
}
|
||||||
|
return content.StandardCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAndValidate(content Content) (Content, error) {
|
||||||
|
content.StandardCopy = strings.TrimSpace(content.StandardCopy)
|
||||||
|
content.ForbiddenCopy = strings.TrimSpace(content.ForbiddenCopy)
|
||||||
|
content.RiskNote = strings.TrimSpace(content.RiskNote)
|
||||||
|
if len(content.CopyTemplates) == 0 && content.StandardCopy != "" {
|
||||||
|
content.CopyTemplates = []CopyTemplate{{ID: "default", Title: "标准话术", Content: content.StandardCopy}}
|
||||||
|
}
|
||||||
|
if len(content.CopyTemplates) == 0 {
|
||||||
|
return Content{}, errors.New("请至少填写一条话术模板")
|
||||||
|
}
|
||||||
|
seenFields := map[string]bool{}
|
||||||
|
for i := range content.Fields {
|
||||||
|
field := &content.Fields[i]
|
||||||
|
field.Key = strings.TrimSpace(field.Key)
|
||||||
|
field.Name = strings.TrimSpace(field.Name)
|
||||||
|
field.SourceField = strings.TrimSpace(field.SourceField)
|
||||||
|
if !contentKeyPattern.MatchString(field.Key) || field.Name == "" {
|
||||||
|
return Content{}, errors.New("知识字段需要有效的字段标识和字段名称")
|
||||||
|
}
|
||||||
|
if seenFields[field.Key] {
|
||||||
|
return Content{}, fmt.Errorf("知识字段标识“%s”重复", field.Key)
|
||||||
|
}
|
||||||
|
if field.SourceField != "" && !contentKeyPattern.MatchString(field.SourceField) {
|
||||||
|
return Content{}, fmt.Errorf("知识字段“%s”的关联字段标识不正确", field.Name)
|
||||||
|
}
|
||||||
|
seenFields[field.Key] = true
|
||||||
|
}
|
||||||
|
seenTemplates := map[string]bool{}
|
||||||
|
for i := range content.CopyTemplates {
|
||||||
|
template := &content.CopyTemplates[i]
|
||||||
|
template.ID = strings.TrimSpace(template.ID)
|
||||||
|
template.Title = strings.TrimSpace(template.Title)
|
||||||
|
template.Content = strings.TrimSpace(template.Content)
|
||||||
|
if !contentKeyPattern.MatchString(template.ID) || template.Title == "" || template.Content == "" {
|
||||||
|
return Content{}, errors.New("每条话术需要有效的标识、标题和内容")
|
||||||
|
}
|
||||||
|
if seenTemplates[template.ID] {
|
||||||
|
return Content{}, fmt.Errorf("话术标识“%s”重复", template.ID)
|
||||||
|
}
|
||||||
|
seenTemplates[template.ID] = true
|
||||||
|
}
|
||||||
|
if content.StandardCopy == "" {
|
||||||
|
content.StandardCopy = content.CopyTemplates[0].Content
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasValue(value interface{}) bool {
|
||||||
|
if value == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(displayValue(value)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayValue(value interface{}) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case bool:
|
||||||
|
if typed {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return "否"
|
||||||
|
case []interface{}:
|
||||||
|
items := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
items = append(items, displayValue(item))
|
||||||
|
}
|
||||||
|
return strings.Join(items, "、")
|
||||||
|
case []string:
|
||||||
|
return strings.Join(typed, "、")
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/knowledge/content_test.go
Normal file
44
internal/knowledge/content_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseContentAcceptsLegacyStandardCopy(t *testing.T) {
|
||||||
|
content, err := ParseContent([]byte(`{"standard_copy":"请说明情况","risk_note":"必要时转诊"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(content.CopyTemplates) != 1 || content.CopyTemplates[0].ID != "default" || content.StandardCopy != "请说明情况" {
|
||||||
|
t.Fatalf("unexpected legacy content: %#v", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateReferencesRejectsUnknownScenarioField(t *testing.T) {
|
||||||
|
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状","source_field":"pet_symptom"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{card.symptom}}"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ValidateReferences(content, map[string]bool{"pet_name": true}); err == nil {
|
||||||
|
t.Fatal("ValidateReferences() error = nil, want unknown source field error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateReferencesRejectsUnknownTemplateField(t *testing.T) {
|
||||||
|
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{input.pet_name}} {{card.disease}}"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ValidateReferences(content, map[string]bool{"pet_name": true}); err == nil {
|
||||||
|
t.Fatal("ValidateReferences() error = nil, want unknown knowledge field error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderResolvesInputAndCardFields(t *testing.T) {
|
||||||
|
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状","value":"未明确","source_field":"symptom"},{"key":"disease","name":"可能疾病","value":"需要医生评估"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{input.pet_name}}出现{{card.symptom}},{{card.disease}}。{{input.pet_age}}岁。"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
copies := Render(content, map[string]interface{}{"pet_name": "团子", "symptom": "呕吐", "pet_age": 3})
|
||||||
|
if len(copies) != 1 || copies[0].Content != "团子出现呕吐,需要医生评估。3岁。" {
|
||||||
|
t.Fatalf("rendered copies = %#v", copies)
|
||||||
|
}
|
||||||
|
}
|
||||||
196
internal/knowledge/graph.go
Normal file
196
internal/knowledge/graph.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var graphKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`)
|
||||||
|
|
||||||
|
type GraphInput struct {
|
||||||
|
Items []GraphItemInput `json:"items"`
|
||||||
|
Relations []GraphRelationInput `json:"relations"`
|
||||||
|
Symptoms []SymptomInput `json:"symptoms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphItemInput struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content map[string]interface{} `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphRelationInput struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
RelationType string `json:"relation_type"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Condition map[string]interface{} `json:"condition"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SymptomInput struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CopyTemplateIDs []string `json:"copy_template_ids"`
|
||||||
|
Diseases []GraphItemInput `json:"diseases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetGraph(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
scenarioID, ok := graphScenarioID(c)
|
||||||
|
if !ok || !access.CanViewScenario(h.db, p, scenarioID) {
|
||||||
|
if ok {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]model.KnowledgeItem, 0)
|
||||||
|
relations := make([]model.KnowledgeRelation, 0)
|
||||||
|
if err := h.db.Where("tenant_id = ? AND scenario_id = ? AND status <> ?", p.TenantID, scenarioID, "archived").Order("sort_order, id").Find(&items).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Order("sort_order, id").Find(&relations).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识关系失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items, "relations": relations})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ReplaceGraph(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
scenarioID, ok := graphScenarioID(c)
|
||||||
|
if !ok || !access.CanEditScenario(h.db, p, scenarioID) {
|
||||||
|
if ok {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input GraphInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识 JSON 格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, relations, err := normalizeGraphInput(input)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var oldItems []model.KnowledgeItem
|
||||||
|
var oldRelations []model.KnowledgeRelation
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldItems).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldRelations).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeRelation{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids := make(map[string]uint64, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
raw, _ := json.Marshal(item.Content)
|
||||||
|
row := model.KnowledgeItem{TenantID: p.TenantID, ScenarioID: scenarioID, ItemKey: item.Key, Name: item.Name, Type: item.Type, Content: datatypes.JSON(raw), Status: item.Status, SortOrder: item.SortOrder}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids[item.Key] = row.ID
|
||||||
|
if err := audit.RecordTx(tx, p, "create", "knowledge_item", row.ID, gin.H{"scenario_id": scenarioID, "key": row.ItemKey}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, relation := range relations {
|
||||||
|
raw, _ := json.Marshal(relation.Condition)
|
||||||
|
row := model.KnowledgeRelation{TenantID: p.TenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[relation.From], RelationType: relation.RelationType, ToKnowledgeID: ids[relation.To], Condition: datatypes.JSON(raw), SortOrder: relation.SortOrder}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := audit.RecordTx(tx, p, "create", "knowledge_relation", row.ID, gin.H{"scenario_id": scenarioID, "from": relation.From, "relation_type": relation.RelationType, "to": relation.To}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, relation := range oldRelations {
|
||||||
|
if err := audit.RecordTx(tx, p, "archive", "knowledge_relation", relation.ID, gin.H{"scenario_id": scenarioID, "from_knowledge_id": relation.FromKnowledgeID, "relation_type": relation.RelationType, "to_knowledge_id": relation.ToKnowledgeID, "sort_order": relation.SortOrder}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range oldItems {
|
||||||
|
if err := audit.RecordTx(tx, p, "archive", "knowledge_item", item.ID, gin.H{"scenario_id": scenarioID, "key": item.ItemKey, "name": item.Name, "type": item.Type, "status": item.Status, "sort_order": item.SortOrder}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存知识关系失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.GetGraph(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGraphInput(input GraphInput) ([]GraphItemInput, []GraphRelationInput, error) {
|
||||||
|
items := append([]GraphItemInput{}, input.Items...)
|
||||||
|
relations := append([]GraphRelationInput{}, input.Relations...)
|
||||||
|
for _, symptom := range input.Symptoms {
|
||||||
|
items = append(items, GraphItemInput{Key: symptom.Key, Name: symptom.Name, Type: "symptom", Status: "active"})
|
||||||
|
for _, disease := range symptom.Diseases {
|
||||||
|
disease.Type = "disease"
|
||||||
|
items = append(items, disease)
|
||||||
|
relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "possible_disease", To: disease.Key})
|
||||||
|
}
|
||||||
|
for _, copyKey := range symptom.CopyTemplateIDs {
|
||||||
|
relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "recommended_copy", To: copyKey})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
unique := make([]GraphItemInput, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if !graphKeyPattern.MatchString(item.Key) || item.Name == "" || item.Type == "" {
|
||||||
|
return nil, nil, fmt.Errorf("知识 key、name 和 type 必须填写且格式正确")
|
||||||
|
}
|
||||||
|
if seen[item.Key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Key] = true
|
||||||
|
if item.Status == "" {
|
||||||
|
item.Status = "active"
|
||||||
|
}
|
||||||
|
if item.Content == nil {
|
||||||
|
item.Content = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
unique = append(unique, item)
|
||||||
|
}
|
||||||
|
for _, relation := range relations {
|
||||||
|
if !seen[relation.From] || !seen[relation.To] || relation.RelationType == "" {
|
||||||
|
return nil, nil, fmt.Errorf("知识关系引用了不存在的 key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique, relations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphScenarioID(c *gin.Context) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
13
internal/knowledge/graph_test.go
Normal file
13
internal/knowledge/graph_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeGraphInputSupportsMultipleDiseases(t *testing.T) {
|
||||||
|
items, relations, err := normalizeGraphInput(GraphInput{Symptoms: []SymptomInput{{Key: "poor_appetite", Name: "食欲下降", Diseases: []GraphItemInput{{Key: "gi", Name: "肠胃不适"}, {Key: "dental", Name: "口腔问题"}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 3 || len(relations) != 2 {
|
||||||
|
t.Fatalf("items=%d relations=%d", len(items), len(relations))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,205 +1,13 @@
|
|||||||
package knowledge
|
package knowledge
|
||||||
|
|
||||||
import (
|
import "gorm.io/gorm"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
// Handler exposes only the scenario knowledge-graph API. Legacy knowledge-card
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
// tables are retained as read-only storage for historical run rendering.
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
type Handler struct {
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
db *gorm.DB
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Handler struct{ db *gorm.DB }
|
|
||||||
|
|
||||||
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
|
|
||||||
|
|
||||||
type input struct {
|
|
||||||
ScenarioID uint64 `json:"scenario_id" binding:"required"`
|
|
||||||
Title string `json:"title" binding:"required,max=128"`
|
|
||||||
Content map[string]interface{} `json:"content" binding:"required"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateInput struct {
|
func NewHandler(db *gorm.DB) *Handler {
|
||||||
Title string `json:"title" binding:"required,max=128"`
|
return &Handler{db: db}
|
||||||
Content map[string]interface{} `json:"content" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
type row struct {
|
|
||||||
model.KnowledgeCard
|
|
||||||
Content datatypes.JSON `json:"content"`
|
|
||||||
Version int `json:"version"`
|
|
||||||
}
|
|
||||||
items := make([]row, 0)
|
|
||||||
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content, kcv.version").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
|
|
||||||
query = access.ScopeScenarios(query, p, "sc")
|
|
||||||
err := query.Where("kc.tenant_id = ? AND kc.status <> ? AND sc.status <> ?", p.TenantID, "archived", "archived").Order("kc.updated_at DESC").Scan(&items).Error
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Update(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil || id == 0 {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body updateInput
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateContent(body.Content); err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content, err := json.Marshal(body.Content)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容格式不正确")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var card model.KnowledgeCard
|
|
||||||
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可编辑")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var version model.KnowledgeCardVersion
|
|
||||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var maxVersion int
|
|
||||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
version = model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: id, Version: maxVersion + 1, Content: datatypes.JSON(content), Status: "published"}
|
|
||||||
if err := tx.Create(&version).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&card).Updates(map[string]interface{}{"title": body.Title, "status": "published"}).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新知识卡失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "update", "knowledge_card", id, gin.H{"version": version.Version})
|
|
||||||
response.OK(c, gin.H{"id": id, "title": body.Title, "version": version.Version, "content": datatypes.JSON(content)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Versions(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil || id == 0 {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var card model.KnowledgeCard
|
|
||||||
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanViewScenario(h.db, p, card.ScenarioID) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]model.KnowledgeCardVersion, 0)
|
|
||||||
if err := h.db.Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Order("version DESC").Find(&items).Error; err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡版本失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Create(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
var body input
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateContent(body.Content); err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanEditScenario(h.db, p, body.ScenarioID) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content, _ := json.Marshal(body.Content)
|
|
||||||
var card model.KnowledgeCard
|
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
card = model.KnowledgeCard{TenantID: p.TenantID, ScenarioID: body.ScenarioID, Title: body.Title, Status: "published", CreatedBy: p.UserID}
|
|
||||||
if err := tx.Create(&card).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Create(&model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: card.ID, Version: 1, Content: datatypes.JSON(content), Status: "published"}).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建知识卡失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "create", "knowledge_card", card.ID, body)
|
|
||||||
response.Created(c, card)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Delete(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var card model.KnowledgeCard
|
|
||||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可归档")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var references int64
|
|
||||||
err = h.db.Table("sop_nodes n").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where(
|
|
||||||
"n.tenant_id = ? AND s.scenario_id = ? AND sv.status IN ? AND n.type = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) = ?",
|
|
||||||
p.TenantID, card.ScenarioID, []string{"published", "offline", "superseded"}, "knowledge", strconv.FormatUint(id, 10),
|
|
||||||
).Count(&references).Error
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查知识卡引用失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if references > 0 {
|
|
||||||
response.Error(c, http.StatusConflict, "KNOWLEDGE_IN_USE", "知识卡已被发布版本引用,不能归档")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result := h.db.Model(&model.KnowledgeCard{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
|
|
||||||
if result.Error != nil || result.RowsAffected == 0 {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "archive", "knowledge_card", id, nil)
|
|
||||||
response.OK(c, gin.H{"id": id})
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateContent(content map[string]interface{}) error {
|
|
||||||
standard, ok := content["standard_copy"].(string)
|
|
||||||
if !ok || strings.TrimSpace(standard) == "" {
|
|
||||||
return errors.New("请填写标准话术")
|
|
||||||
}
|
|
||||||
for _, key := range []string{"forbidden_copy", "risk_note"} {
|
|
||||||
if value, exists := content[key]; exists {
|
|
||||||
if _, ok := value.(string); !ok {
|
|
||||||
return errors.New("知识卡文本字段格式不正确")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package knowledge
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateContent(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
content map[string]interface{}
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{name: "valid", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": "风险提示"}},
|
|
||||||
{name: "missing standard copy", content: map[string]interface{}{"risk_note": "风险提示"}, wantErr: true},
|
|
||||||
{name: "blank standard copy", content: map[string]interface{}{"standard_copy": " "}, wantErr: true},
|
|
||||||
{name: "invalid risk note", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": 1}, wantErr: true},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
if err := validateContent(test.content); (err != nil) != test.wantErr {
|
|
||||||
t.Fatalf("validateContent() error = %v, wantErr %v", err, test.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,22 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CORS 完全开放跨域访问:允许所有来源、方法和请求头,并直接响应预检请求。
|
||||||
|
func CORS() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "*")
|
||||||
|
c.Header("Access-Control-Expose-Headers", "X-Request-ID, Content-Length")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RequestLogger(log *zap.Logger) gin.HandlerFunc {
|
func RequestLogger(log *zap.Logger) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
|
|||||||
@@ -30,3 +30,34 @@ func TestRequestLoggerUsesStatusLevelAndDurationMilliseconds(t *testing.T) {
|
|||||||
t.Fatalf("duration_ms is missing from request log")
|
t.Fatalf("duration_ms is missing from request log")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCORSAllowsAllOriginsAndAnswersPreflight(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(CORS())
|
||||||
|
router.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
req.Header.Set("Origin", "https://crm.example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||||
|
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "*" {
|
||||||
|
t.Fatalf("Access-Control-Allow-Headers = %q, want *", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
preflight := httptest.NewRequest(http.MethodOptions, "/ping", nil)
|
||||||
|
preflight.Header.Set("Origin", "https://crm.example.com")
|
||||||
|
preflightRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(preflightRec, preflight)
|
||||||
|
|
||||||
|
if preflightRec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("preflight status = %d, want %d", preflightRec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if got := preflightRec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||||
|
t.Fatalf("preflight Access-Control-Allow-Origin = %q, want *", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ type TenantMember struct {
|
|||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
Base
|
Base
|
||||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
ScenarioKey string `json:"scenario_key" gorm:"size:128;not null;index"`
|
||||||
|
PublicKey string `json:"public_key" gorm:"size:128;not null;index"`
|
||||||
|
AllowedOrigins datatypes.JSON `json:"allowed_origins" gorm:"type:json;not null"`
|
||||||
Name string `json:"name" gorm:"size:128;not null"`
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
Industry string `json:"industry" gorm:"size:64;not null"`
|
Industry string `json:"industry" gorm:"size:64;not null"`
|
||||||
RoleName string `json:"role_name" gorm:"size:64;not null"`
|
RoleName string `json:"role_name" gorm:"size:64;not null"`
|
||||||
@@ -54,6 +57,9 @@ type Scenario struct {
|
|||||||
Visibility string `json:"visibility" gorm:"size:24;not null"`
|
Visibility string `json:"visibility" gorm:"size:24;not null"`
|
||||||
Status string `json:"status" gorm:"size:24;not null"`
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||||
|
InputSchema datatypes.JSON `json:"input_schema" gorm:"type:json;not null"`
|
||||||
|
OutputSchema datatypes.JSON `json:"output_schema" gorm:"type:json;not null"`
|
||||||
|
ResultSchema datatypes.JSON `json:"result_schema" gorm:"type:json;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScenarioField struct {
|
type ScenarioField struct {
|
||||||
@@ -62,6 +68,7 @@ type ScenarioField struct {
|
|||||||
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||||
FieldKey string `json:"field_key" gorm:"size:64;not null"`
|
FieldKey string `json:"field_key" gorm:"size:64;not null"`
|
||||||
FieldName string `json:"field_name" gorm:"size:128;not null"`
|
FieldName string `json:"field_name" gorm:"size:128;not null"`
|
||||||
|
SourcePath string `json:"source_path" gorm:"size:255;not null"`
|
||||||
FieldType string `json:"field_type" gorm:"size:32;not null"`
|
FieldType string `json:"field_type" gorm:"size:32;not null"`
|
||||||
Required bool `json:"required" gorm:"not null"`
|
Required bool `json:"required" gorm:"not null"`
|
||||||
Options datatypes.JSON `json:"options" gorm:"type:json;not null"`
|
Options datatypes.JSON `json:"options" gorm:"type:json;not null"`
|
||||||
@@ -69,6 +76,18 @@ type ScenarioField struct {
|
|||||||
SortOrder int `json:"sort_order" gorm:"not null"`
|
SortOrder int `json:"sort_order" gorm:"not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScenarioRule struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||||
|
RuleKey string `json:"rule_key" gorm:"size:64;not null"`
|
||||||
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
|
Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
|
||||||
|
Actions datatypes.JSON `json:"actions" gorm:"type:json;not null"`
|
||||||
|
Priority int `json:"priority" gorm:"not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
type SOP struct {
|
type SOP struct {
|
||||||
Base
|
Base
|
||||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
@@ -88,7 +107,6 @@ type SOPVersion struct {
|
|||||||
StartNodeKey string `json:"start_node_key" gorm:"size:64;not null"`
|
StartNodeKey string `json:"start_node_key" gorm:"size:64;not null"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||||
ReviewedBy *uint64 `json:"reviewed_by"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SOPNode struct {
|
type SOPNode struct {
|
||||||
@@ -132,16 +150,45 @@ type KnowledgeCardVersion struct {
|
|||||||
Status string `json:"status" gorm:"size:24;not null"`
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KnowledgeItem struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||||
|
ItemKey string `json:"key" gorm:"size:64;not null"`
|
||||||
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
|
Type string `json:"type" gorm:"size:64;not null"`
|
||||||
|
Content datatypes.JSON `json:"content" gorm:"type:json;not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
SortOrder int `json:"sort_order" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeRelation struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||||
|
FromKnowledgeID uint64 `json:"from_knowledge_id" gorm:"not null;index"`
|
||||||
|
RelationType string `json:"relation_type" gorm:"size:64;not null"`
|
||||||
|
ToKnowledgeID uint64 `json:"to_knowledge_id" gorm:"not null;index"`
|
||||||
|
Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
|
||||||
|
SortOrder int `json:"sort_order" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
type SOPRun struct {
|
type SOPRun struct {
|
||||||
Base
|
Base
|
||||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
||||||
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
||||||
OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
|
OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
|
||||||
|
ExternalRef string `json:"external_ref" gorm:"size:191;not null;index"`
|
||||||
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
|
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
|
||||||
Status string `json:"status" gorm:"size:24;not null"`
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"`
|
Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"`
|
||||||
|
Input datatypes.JSON `json:"input" gorm:"type:json;not null"`
|
||||||
|
Derived datatypes.JSON `json:"derived" gorm:"type:json;not null"`
|
||||||
|
Outputs datatypes.JSON `json:"outputs" gorm:"type:json;not null"`
|
||||||
|
KnowledgeSnapshot datatypes.JSON `json:"knowledge_snapshot" gorm:"type:json;not null"`
|
||||||
Result string `json:"result" gorm:"size:64;not null"`
|
Result string `json:"result" gorm:"size:64;not null"`
|
||||||
|
FinalResult datatypes.JSON `json:"final_result" gorm:"type:json"`
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
CompletedAt *time.Time `json:"completed_at"`
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
}
|
}
|
||||||
@@ -155,6 +202,14 @@ type SOPRunEvent struct {
|
|||||||
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
|
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicRunSession struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
RunID uint64 `json:"run_id" gorm:"not null;index"`
|
||||||
|
TokenHash string `json:"-" gorm:"size:64;not null;uniqueIndex"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
type SOPFeedback struct {
|
type SOPFeedback struct {
|
||||||
Base
|
Base
|
||||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
@@ -176,6 +231,23 @@ type AuditLog struct {
|
|||||||
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
|
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MultiTableOutbox struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `gorm:"not null;index"`
|
||||||
|
AuditLogID *uint64 `gorm:"uniqueIndex"`
|
||||||
|
Resource string `gorm:"size:64;not null;index"`
|
||||||
|
ResourceID uint64 `gorm:"not null;index"`
|
||||||
|
Action string `gorm:"size:64;not null"`
|
||||||
|
Payload datatypes.JSON `gorm:"type:json;not null"`
|
||||||
|
DedupeKey string `gorm:"size:191;not null;uniqueIndex"`
|
||||||
|
Status string `gorm:"size:24;not null;index"`
|
||||||
|
Attempts int `gorm:"not null"`
|
||||||
|
AvailableAt time.Time `gorm:"not null;index"`
|
||||||
|
LastError string `gorm:"type:text;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MultiTableOutbox) TableName() string { return "multitable_outbox" }
|
||||||
|
|
||||||
type RefreshToken struct {
|
type RefreshToken struct {
|
||||||
Base
|
Base
|
||||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
|||||||
137
internal/multitable/client.go
Normal file
137
internal/multitable/client.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package multitable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
http *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
sourceColumns map[uint64]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, apiKey string, timeout time.Duration) *Client {
|
||||||
|
return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, http: &http.Client{Timeout: timeout}, sourceColumns: make(map[uint64]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Upsert(ctx context.Context, tableID uint64, sourceID string, data map[string]interface{}) error {
|
||||||
|
sourceColumn, err := c.sourceColumn(ctx, tableID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filters, err := json.Marshal([]map[string]interface{}{{"col": sourceColumn, "op": "eq", "val": sourceID}})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode filters: %w", err)
|
||||||
|
}
|
||||||
|
endpoint := c.endpoint("tables", fmt.Sprint(tableID), "records")
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse records endpoint: %w", err)
|
||||||
|
}
|
||||||
|
query := u.Query()
|
||||||
|
query.Set("page", "1")
|
||||||
|
query.Set("pageSize", "2")
|
||||||
|
query.Set("filters", string(filters))
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
var listed struct {
|
||||||
|
Items []struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &listed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"data": data}
|
||||||
|
if len(listed.Items) == 0 {
|
||||||
|
return c.doJSON(ctx, http.MethodPost, endpoint, body, nil)
|
||||||
|
}
|
||||||
|
return c.doJSON(ctx, http.MethodPut, c.endpoint("tables", fmt.Sprint(tableID), "records", fmt.Sprint(listed.Items[0].ID)), body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sourceColumn(ctx context.Context, tableID uint64) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if columnID, ok := c.sourceColumns[tableID]; ok {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return columnID, nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
var columns []struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, http.MethodGet, c.endpoint("tables", fmt.Sprint(tableID), "columns"), nil, &columns); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, column := range columns {
|
||||||
|
if column.Name == "来源ID" {
|
||||||
|
columnID := fmt.Sprint(column.ID)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.sourceColumns[tableID] = columnID
|
||||||
|
c.mu.Unlock()
|
||||||
|
return columnID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("multitable table %d has no 来源ID column", tableID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) endpoint(parts ...string) string {
|
||||||
|
return c.baseURL + "/" + path.Join(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSON(ctx context.Context, method, endpoint string, input interface{}, output interface{}) error {
|
||||||
|
var body io.Reader
|
||||||
|
if input != nil {
|
||||||
|
encoded, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode request: %w", err)
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-API-Key", c.apiKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if input != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("call multitable API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read multitable response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return fmt.Errorf("multitable API returned %d: %s", resp.StatusCode, truncate(string(data), 512))
|
||||||
|
}
|
||||||
|
if output != nil && len(data) > 0 {
|
||||||
|
if err := json.Unmarshal(data, output); err != nil {
|
||||||
|
return fmt.Errorf("decode multitable response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(value string, max int) string {
|
||||||
|
if len(value) <= max {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:max]
|
||||||
|
}
|
||||||
120
internal/multitable/client_test.go
Normal file
120
internal/multitable/client_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package multitable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpsertCreatesWhenSourceIDDoesNotExist(t *testing.T) {
|
||||||
|
var gotData map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if r.URL.Path == "/tables/46/columns" {
|
||||||
|
_ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 1, "name": "来源ID"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("filters") == "" {
|
||||||
|
t.Fatal("missing source ID filter")
|
||||||
|
}
|
||||||
|
var filters []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(r.URL.Query().Get("filters")), &filters); err != nil || filters[0]["col"] != "1" {
|
||||||
|
t.Fatalf("filters = %s, want column ID 1", r.URL.Query().Get("filters"))
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}})
|
||||||
|
case http.MethodPost:
|
||||||
|
var body struct {
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotData = body.Data
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := NewClient(server.URL, "test-key", time.Second).Upsert(context.Background(), 46, "42", map[string]interface{}{"来源ID": "42", "名称": "宠物医生问诊问药"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upsert() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotData["来源ID"] != "42" {
|
||||||
|
t.Fatalf("created data = %#v", gotData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertUpdatesExistingRecord(t *testing.T) {
|
||||||
|
updated := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if r.URL.Path == "/tables/46/columns" {
|
||||||
|
_ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 1, "name": "来源ID"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []map[string]interface{}{{"id": 99}}})
|
||||||
|
case http.MethodPut:
|
||||||
|
if r.URL.Path != "/tables/46/records/99" {
|
||||||
|
t.Fatalf("update path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
updated = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := NewClient(server.URL, "test-key", time.Second).Upsert(context.Background(), 46, "42", map[string]interface{}{"来源ID": "42"}); err != nil {
|
||||||
|
t.Fatalf("Upsert() error = %v", err)
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
t.Fatal("existing record was not updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletedScenarioFieldCreatesArchivedProjectionFromAuditPayload(t *testing.T) {
|
||||||
|
var created map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/tables/47/columns" {
|
||||||
|
_ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 1, "name": "来源ID"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var body struct {
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
created = body.Data
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
payload := []byte(`{"scenario_id":9,"field_key":"pet_name","field_name":"宠物姓名","source_path":"pet.name","field_type":"text","required":true,"options":[],"validation":{},"sort_order":1}`)
|
||||||
|
projector := NewProjector(nil, NewClient(server.URL, "test-key", time.Second), config.MultiTableTables{ScenarioFields: 47})
|
||||||
|
event := model.MultiTableOutbox{Base: model.Base{ID: 3, UpdatedAt: time.Now()}, TenantID: 2, ResourceID: 12, Action: "delete", Payload: payload}
|
||||||
|
if err := projector.deletedScenarioField(context.Background(), event); err != nil {
|
||||||
|
t.Fatalf("deletedScenarioField() error = %v", err)
|
||||||
|
}
|
||||||
|
if created["同步状态"] != "已归档" || created["字段标识"] != "pet_name" || created["外部数据路径"] != "pet.name" || created["是否必填"] != "是" {
|
||||||
|
t.Fatalf("created projection = %#v", created)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/multitable/outbox.go
Normal file
44
internal/multitable/outbox.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package multitable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EnqueueBackfill(db *gorm.DB) (int, error) {
|
||||||
|
resources := []struct {
|
||||||
|
name string
|
||||||
|
model interface{}
|
||||||
|
}{
|
||||||
|
{"scenario", &model.Scenario{}},
|
||||||
|
{"scenario_field", &model.ScenarioField{}},
|
||||||
|
{"scenario_rule", &model.ScenarioRule{}},
|
||||||
|
{"sop", &model.SOP{}},
|
||||||
|
{"knowledge_item", &model.KnowledgeItem{}},
|
||||||
|
{"knowledge_relation", &model.KnowledgeRelation{}},
|
||||||
|
{"sop_run", &model.SOPRun{}},
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, resource := range resources {
|
||||||
|
var rows []struct{ ID, TenantID uint64 }
|
||||||
|
if err := db.Model(resource.model).Select("id, tenant_id").Scan(&rows).Error; err != nil {
|
||||||
|
return count, fmt.Errorf("list %s: %w", resource.name, err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
event := model.MultiTableOutbox{
|
||||||
|
TenantID: row.TenantID, Resource: resource.name, ResourceID: row.ID, Action: "backfill",
|
||||||
|
Payload: []byte(`{}`), DedupeKey: fmt.Sprintf("backfill:%s:%d", resource.name, row.ID),
|
||||||
|
Status: "pending", Attempts: 0, AvailableAt: time.Now(), LastError: "",
|
||||||
|
}
|
||||||
|
if err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "dedupe_key"}}, DoUpdates: clause.Assignments(map[string]interface{}{"status": "pending", "attempts": 0, "available_at": time.Now(), "last_error": ""})}).Create(&event).Error; err != nil {
|
||||||
|
return count, fmt.Errorf("enqueue %s %d: %w", resource.name, row.ID, err)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
335
internal/multitable/projector.go
Normal file
335
internal/multitable/projector.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package multitable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Projector struct {
|
||||||
|
db *gorm.DB
|
||||||
|
client *Client
|
||||||
|
tables config.MultiTableTables
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjector(db *gorm.DB, client *Client, tables config.MultiTableTables) *Projector {
|
||||||
|
return &Projector{db: db, client: client, tables: tables}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) Project(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
switch event.Resource {
|
||||||
|
case "scenario":
|
||||||
|
return p.scenario(ctx, event)
|
||||||
|
case "scenario_field":
|
||||||
|
return p.scenarioField(ctx, event)
|
||||||
|
case "scenario_rule":
|
||||||
|
return p.scenarioRule(ctx, event)
|
||||||
|
case "sop":
|
||||||
|
return p.sop(ctx, event)
|
||||||
|
case "knowledge_item":
|
||||||
|
return p.knowledgeItem(ctx, event)
|
||||||
|
case "knowledge_relation":
|
||||||
|
return p.knowledgeRelation(ctx, event)
|
||||||
|
case "sop_run":
|
||||||
|
if err := p.run(ctx, event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if event.Action == "feedback" {
|
||||||
|
return p.feedback(ctx, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) scenario(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var item model.Scenario
|
||||||
|
if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.client.Upsert(ctx, p.tables.Scenarios, id(item.ID), common(item.ID, item.TenantID, statusFor(item.Status), item.UpdatedAt, map[string]interface{}{
|
||||||
|
"名称": item.Name, "行业": item.Industry, "适用角色": item.RoleName, "目标": clip(item.Goal), "触发条件": clip(item.TriggerText), "可见范围": item.Visibility, "业务状态": item.Status, "创建人ID": id(item.CreatedBy),
|
||||||
|
})); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if item.Status != "archived" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields := make([]model.ScenarioField, 0)
|
||||||
|
if err := p.db.Where("scenario_id = ? AND tenant_id = ?", item.ID, item.TenantID).Find(&fields).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
if err := p.projectScenarioField(ctx, field, "已归档"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sops := make([]model.SOP, 0)
|
||||||
|
if err := p.db.Where("scenario_id = ? AND tenant_id = ?", item.ID, item.TenantID).Find(&sops).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, sop := range sops {
|
||||||
|
if err := p.sop(ctx, model.MultiTableOutbox{TenantID: item.TenantID, ResourceID: sop.ID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) scenarioField(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var item model.ScenarioField
|
||||||
|
if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) && event.Action == "delete" {
|
||||||
|
return p.deletedScenarioField(ctx, event)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.projectScenarioField(ctx, item, "正常")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) projectScenarioField(ctx context.Context, item model.ScenarioField, syncStatus string) error {
|
||||||
|
return p.client.Upsert(ctx, p.tables.ScenarioFields, id(item.ID), common(item.ID, item.TenantID, syncStatus, item.UpdatedAt, map[string]interface{}{
|
||||||
|
"场景来源ID": id(item.ScenarioID), "字段标识": item.FieldKey, "字段名称": item.FieldName, "外部数据路径": item.SourcePath, "字段类型": item.FieldType, "是否必填": yesNo(item.Required), "选项": jsonText(item.Options), "校验规则": jsonText(item.Validation), "排序": item.SortOrder,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) scenarioRule(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var item model.ScenarioRule
|
||||||
|
err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error
|
||||||
|
if err == nil {
|
||||||
|
return p.client.Upsert(ctx, p.tables.ScenarioRules, id(item.ID), scenarioRuleProjection(item, "正常"))
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ScenarioID uint64 `json:"scenario_id"`
|
||||||
|
RuleKey string `json:"rule_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.client.Upsert(ctx, p.tables.ScenarioRules, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{
|
||||||
|
"场景来源ID": id(payload.ScenarioID), "规则标识": payload.RuleKey, "名称": payload.Name, "优先级": payload.Priority, "规则状态": payload.Status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) deletedScenarioField(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var payload struct {
|
||||||
|
ScenarioID uint64 `json:"scenario_id"`
|
||||||
|
FieldKey string `json:"field_key"`
|
||||||
|
FieldName string `json:"field_name"`
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
FieldType string `json:"field_type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Options json.RawMessage `json:"options"`
|
||||||
|
Validation json.RawMessage `json:"validation"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if payload.ScenarioID == 0 || payload.FieldKey == "" || payload.FieldName == "" || payload.FieldType == "" {
|
||||||
|
return errors.New("deleted scenario field audit payload is incomplete")
|
||||||
|
}
|
||||||
|
item := model.ScenarioField{Base: model.Base{ID: event.ResourceID, UpdatedAt: event.UpdatedAt}, TenantID: event.TenantID, ScenarioID: payload.ScenarioID, FieldKey: payload.FieldKey, FieldName: payload.FieldName, SourcePath: payload.SourcePath, FieldType: payload.FieldType, Required: payload.Required, Options: datatypes.JSON(payload.Options), Validation: datatypes.JSON(payload.Validation), SortOrder: payload.SortOrder}
|
||||||
|
return p.projectScenarioField(ctx, item, "已归档")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) sop(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var sop model.SOP
|
||||||
|
if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&sop).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
versions := make([]model.SOPVersion, 0)
|
||||||
|
if err := p.db.Where("sop_id = ? AND tenant_id = ? AND status IN ?", sop.ID, sop.TenantID, []string{"published", "superseded", "offline"}).Find(&versions).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, version := range versions {
|
||||||
|
if err := p.sopVersion(ctx, sop, version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) sopVersion(ctx context.Context, sop model.SOP, version model.SOPVersion) error {
|
||||||
|
nodes := make([]model.SOPNode, 0)
|
||||||
|
edges := make([]model.SOPEdge, 0)
|
||||||
|
if err := p.db.Where("sop_version_id = ?", version.ID).Order("id").Find(&nodes).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.db.Where("sop_version_id = ?", version.ID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state := statusFor(version.Status)
|
||||||
|
if sop.Status == "archived" {
|
||||||
|
state = "已归档"
|
||||||
|
}
|
||||||
|
if err := p.client.Upsert(ctx, p.tables.SOPVersions, id(version.ID), common(version.ID, version.TenantID, state, version.UpdatedAt, map[string]interface{}{
|
||||||
|
"SOP来源ID": id(sop.ID), "场景来源ID": id(sop.ScenarioID), "SOP名称": sop.Name, "版本号": version.Version, "版本状态": version.Status, "开始节点": version.StartNodeKey, "发布时间": formatTime(version.PublishedAt), "节点数": len(nodes), "路径数": len(edges),
|
||||||
|
})); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, node := range nodes {
|
||||||
|
if err := p.client.Upsert(ctx, p.tables.SOPNodes, id(node.ID), common(node.ID, node.TenantID, state, node.UpdatedAt, map[string]interface{}{
|
||||||
|
"SOP版本来源ID": id(version.ID), "节点标识": node.NodeKey, "节点类型": node.Type, "标题": node.Title, "标准话术或操作提示": clip(node.Content), "配置": jsonText(node.Config), "排序": node.PositionY,
|
||||||
|
})); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, edge := range edges {
|
||||||
|
if err := p.client.Upsert(ctx, p.tables.SOPEdges, id(edge.ID), common(edge.ID, edge.TenantID, state, edge.UpdatedAt, map[string]interface{}{
|
||||||
|
"SOP版本来源ID": id(version.ID), "起点节点标识": edge.SourceNodeKey, "终点节点标识": edge.TargetNodeKey, "条件": jsonText(edge.Condition), "优先级": edge.Priority,
|
||||||
|
})); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) knowledgeItem(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var item model.KnowledgeItem
|
||||||
|
err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error
|
||||||
|
if err == nil {
|
||||||
|
return p.client.Upsert(ctx, p.tables.KnowledgeItems, id(item.ID), knowledgeItemProjection(item, statusFor(item.Status)))
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ScenarioID uint64 `json:"scenario_id"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.client.Upsert(ctx, p.tables.KnowledgeItems, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{
|
||||||
|
"场景来源ID": id(payload.ScenarioID), "知识标识": payload.Key, "名称": payload.Name, "知识类型": payload.Type, "知识状态": payload.Status, "排序": payload.SortOrder,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) knowledgeRelation(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var item model.KnowledgeRelation
|
||||||
|
err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error
|
||||||
|
if err == nil {
|
||||||
|
return p.client.Upsert(ctx, p.tables.KnowledgeRelations, id(item.ID), knowledgeRelationProjection(item, "正常"))
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ScenarioID uint64 `json:"scenario_id"`
|
||||||
|
FromKnowledge uint64 `json:"from_knowledge_id"`
|
||||||
|
RelationType string `json:"relation_type"`
|
||||||
|
ToKnowledge uint64 `json:"to_knowledge_id"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.client.Upsert(ctx, p.tables.KnowledgeRelations, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{
|
||||||
|
"场景来源ID": id(payload.ScenarioID), "起点知识来源ID": id(payload.FromKnowledge), "关系类型": payload.RelationType, "终点知识来源ID": id(payload.ToKnowledge), "排序": payload.SortOrder,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) run(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
var row struct {
|
||||||
|
model.SOPRun
|
||||||
|
SOPName string
|
||||||
|
ScenarioName string
|
||||||
|
}
|
||||||
|
err := p.db.Table("sop_runs r").Select("r.*, s.name AS sop_name, sc.name AS scenario_name").Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Where("r.id = ? AND r.tenant_id = ?", event.ResourceID, event.TenantID).Scan(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if row.ID == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
var answerCount int
|
||||||
|
var answers map[string]interface{}
|
||||||
|
_ = json.Unmarshal(row.Answers, &answers)
|
||||||
|
answerCount = len(answers)
|
||||||
|
return p.client.Upsert(ctx, p.tables.Runs, id(row.ID), common(row.ID, row.TenantID, "正常", row.UpdatedAt, map[string]interface{}{
|
||||||
|
"SOP来源ID": id(row.SOPID), "SOP版本来源ID": id(row.SOPVersionID), "场景名称": row.ScenarioName, "执行人ID": id(row.OperatorID), "执行状态": row.Status, "执行结果": row.Result, "最终结果": jsonText(row.FinalResult), "已采集字段数": answerCount, "开始时间": formatTime(&row.StartedAt), "完成时间": formatTime(row.CompletedAt),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Projector) feedback(ctx context.Context, event model.MultiTableOutbox) error {
|
||||||
|
items := make([]model.SOPFeedback, 0)
|
||||||
|
if err := p.db.Where("run_id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).Find(&items).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if err := p.client.Upsert(ctx, p.tables.Feedback, id(item.ID), common(item.ID, item.TenantID, "正常", item.UpdatedAt, map[string]interface{}{
|
||||||
|
"执行来源ID": id(item.RunID), "提交人ID": id(item.UserID), "评分": item.Score, "反馈内容": clip(item.Comment), "提交时间": item.CreatedAt.Format(time.RFC3339),
|
||||||
|
})); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func common(sourceID, tenantID uint64, status string, updated time.Time, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
data := map[string]interface{}{"来源ID": id(sourceID), "业务租户ID": tenantID, "同步状态": status, "来源更新时间": updated.Format(time.RFC3339)}
|
||||||
|
for key, value := range extra {
|
||||||
|
data[key] = value
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenarioRuleProjection(item model.ScenarioRule, status string) map[string]interface{} {
|
||||||
|
return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "规则标识": item.RuleKey, "名称": item.Name, "优先级": item.Priority, "规则状态": item.Status})
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeItemProjection(item model.KnowledgeItem, status string) map[string]interface{} {
|
||||||
|
return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "知识标识": item.ItemKey, "名称": item.Name, "知识类型": item.Type, "知识状态": item.Status, "排序": item.SortOrder})
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeRelationProjection(item model.KnowledgeRelation, status string) map[string]interface{} {
|
||||||
|
return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "起点知识来源ID": id(item.FromKnowledgeID), "关系类型": item.RelationType, "终点知识来源ID": id(item.ToKnowledgeID), "排序": item.SortOrder})
|
||||||
|
}
|
||||||
|
|
||||||
|
func id(value uint64) string { return strconv.FormatUint(value, 10) }
|
||||||
|
func formatTime(value *time.Time) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
func statusFor(value string) string {
|
||||||
|
if value == "archived" {
|
||||||
|
return "已归档"
|
||||||
|
}
|
||||||
|
if value == "offline" {
|
||||||
|
return "已下线"
|
||||||
|
}
|
||||||
|
return "正常"
|
||||||
|
}
|
||||||
|
func jsonText(value []byte) string { return clip(string(value)) }
|
||||||
|
func stringValue(value interface{}) string { result, _ := value.(string); return result }
|
||||||
|
func clip(value string) string {
|
||||||
|
if len(value) > 4000 {
|
||||||
|
return value[:4000]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
func yesNo(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return "否"
|
||||||
|
}
|
||||||
30
internal/multitable/projector_test.go
Normal file
30
internal/multitable/projector_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package multitable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewResourceProjections(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data map[string]interface{}
|
||||||
|
want map[string]interface{}
|
||||||
|
}{
|
||||||
|
{"scenario rule", scenarioRuleProjection(model.ScenarioRule{Base: model.Base{ID: 11, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, RuleKey: "match", Name: "匹配", Priority: 10, Status: "active"}, "正常"), map[string]interface{}{"来源ID": "11", "场景来源ID": "3", "规则标识": "match", "优先级": 10}},
|
||||||
|
{"knowledge item", knowledgeItemProjection(model.KnowledgeItem{Base: model.Base{ID: 12, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, ItemKey: "soft", Name: "软便", Type: "symptom", Status: "active", SortOrder: 4}, "正常"), map[string]interface{}{"来源ID": "12", "知识标识": "soft", "知识类型": "symptom", "排序": 4}},
|
||||||
|
{"knowledge relation", knowledgeRelationProjection(model.KnowledgeRelation{Base: model.Base{ID: 13, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, FromKnowledgeID: 12, RelationType: "recommended_copy", ToKnowledgeID: 14, SortOrder: 5}, "正常"), map[string]interface{}{"来源ID": "13", "起点知识来源ID": "12", "关系类型": "recommended_copy", "终点知识来源ID": "14"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
for key, want := range test.want {
|
||||||
|
if got := test.data[key]; got != want {
|
||||||
|
t.Fatalf("%s=%v want %v; data=%#v", key, got, want, test.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
101
internal/multitable/worker.go
Normal file
101
internal/multitable/worker.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package multitable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Worker struct {
|
||||||
|
db *gorm.DB
|
||||||
|
projector *Projector
|
||||||
|
config config.MultiTableConfig
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorker(db *gorm.DB, cfg config.MultiTableConfig, log *zap.Logger) *Worker {
|
||||||
|
return &Worker{db: db, projector: NewProjector(db, NewClient(cfg.BaseURL, cfg.APIKey, cfg.RequestTimeout), cfg.Tables), config: cfg, log: log.Named("multitable")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) Run(ctx context.Context) {
|
||||||
|
// A prior process may have stopped while an event was claimed.
|
||||||
|
w.db.Model(&model.MultiTableOutbox{}).Where("status = ?", "processing").Update("status", "pending")
|
||||||
|
w.process(ctx)
|
||||||
|
ticker := time.NewTicker(w.config.SyncInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.process(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) process(ctx context.Context) {
|
||||||
|
for _, event := range w.claim() {
|
||||||
|
if err := w.projector.Project(ctx, event); err != nil {
|
||||||
|
w.retry(event, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := w.db.Model(&model.MultiTableOutbox{}).Where("id = ?", event.ID).Updates(map[string]interface{}{"status": "succeeded", "last_error": ""}).Error; err != nil {
|
||||||
|
w.log.Error("mark outbox succeeded", zap.Uint64("event_id", event.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) claim() []model.MultiTableOutbox {
|
||||||
|
items := make([]model.MultiTableOutbox, 0)
|
||||||
|
err := w.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? AND available_at <= ?", "pending", time.Now()).Order("id").Limit(w.config.BatchSize).Find(&items).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if err := tx.Model(&model.MultiTableOutbox{}).Where("id = ? AND status = ?", items[i].ID, "pending").Updates(map[string]interface{}{"status": "processing", "attempts": gorm.Expr("attempts + 1")}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items[i].Attempts++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
w.log.Error("claim multitable outbox", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) retry(event model.MultiTableOutbox, cause error) {
|
||||||
|
status := "pending"
|
||||||
|
availableAt := time.Now().Add(backoff(event.Attempts))
|
||||||
|
if event.Attempts >= w.config.MaxAttempts {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
message := truncate(cause.Error(), 1024)
|
||||||
|
if err := w.db.Model(&model.MultiTableOutbox{}).Where("id = ?", event.ID).Updates(map[string]interface{}{"status": status, "available_at": availableAt, "last_error": message}).Error; err != nil {
|
||||||
|
w.log.Error("reschedule multitable event", zap.Uint64("event_id", event.ID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.log.Warn("multitable projection failed", zap.Uint64("event_id", event.ID), zap.String("resource", event.Resource), zap.String("action", event.Action), zap.String("status", status), zap.Error(cause))
|
||||||
|
}
|
||||||
|
|
||||||
|
func backoff(attempt int) time.Duration {
|
||||||
|
if attempt < 1 {
|
||||||
|
attempt = 1
|
||||||
|
}
|
||||||
|
if attempt > 8 {
|
||||||
|
attempt = 8
|
||||||
|
}
|
||||||
|
return time.Second * time.Duration(1<<(attempt-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) String() string {
|
||||||
|
return fmt.Sprintf("multitable worker (every %s)", w.config.SyncInterval)
|
||||||
|
}
|
||||||
196
internal/resultcontract/schema.go
Normal file
196
internal/resultcontract/schema.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package resultcontract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var keyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
type Schema struct {
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Options []any `json:"options,omitempty"`
|
||||||
|
Fields []Field `json:"fields,omitempty"`
|
||||||
|
Items *Field `json:"items,omitempty"`
|
||||||
|
Min *float64 `json:"min,omitempty"`
|
||||||
|
Max *float64 `json:"max,omitempty"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAndValidate(raw []byte) (Schema, error) {
|
||||||
|
var schema Schema
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return schema, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||||
|
return schema, fmt.Errorf("result_schema 格式不正确")
|
||||||
|
}
|
||||||
|
if err := validateFields(schema.Fields, 0); err != nil {
|
||||||
|
return schema, err
|
||||||
|
}
|
||||||
|
return schema, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateSchema(value map[string]interface{}) error {
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("result_schema 格式不正确")
|
||||||
|
}
|
||||||
|
_, err = ParseAndValidate(raw)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateResult permits nil and an empty object so a run can finish without a business outcome.
|
||||||
|
func ValidateResult(schema Schema, value map[string]interface{}) error {
|
||||||
|
if len(value) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return validateObject(schema.Fields, value, "final_result")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFields(fields []Field, depth int) error {
|
||||||
|
if depth > 8 {
|
||||||
|
return fmt.Errorf("result_schema 嵌套层级不能超过 8 层")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, field := range fields {
|
||||||
|
if !keyPattern.MatchString(field.Key) || seen[field.Key] {
|
||||||
|
return fmt.Errorf("结果字段标识必须唯一且格式正确")
|
||||||
|
}
|
||||||
|
seen[field.Key] = true
|
||||||
|
if field.Name == "" {
|
||||||
|
return fmt.Errorf("结果字段 %s 缺少名称", field.Key)
|
||||||
|
}
|
||||||
|
if !supportedType(field.Type) {
|
||||||
|
return fmt.Errorf("结果字段 %s 使用了不支持的类型 %s", field.Key, field.Type)
|
||||||
|
}
|
||||||
|
if field.Type == "object" {
|
||||||
|
if len(field.Fields) == 0 {
|
||||||
|
return fmt.Errorf("对象字段 %s 必须定义 fields", field.Key)
|
||||||
|
}
|
||||||
|
if err := validateFields(field.Fields, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if field.Type == "array" && field.Items != nil {
|
||||||
|
item := *field.Items
|
||||||
|
if item.Type == "" {
|
||||||
|
return fmt.Errorf("数组字段 %s 的 items 必须定义类型", field.Key)
|
||||||
|
}
|
||||||
|
if item.Type == "object" {
|
||||||
|
if len(item.Fields) == 0 {
|
||||||
|
return fmt.Errorf("数组字段 %s 的对象项必须定义 fields", field.Key)
|
||||||
|
}
|
||||||
|
if err := validateFields(item.Fields, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !supportedType(item.Type) || item.Type == "array" {
|
||||||
|
return fmt.Errorf("数组字段 %s 的 items 类型不正确", field.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportedType(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "string", "text", "textarea", "number", "integer", "boolean", "select", "multiselect", "date", "array", "object", "any":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateObject(fields []Field, value map[string]interface{}, path string) error {
|
||||||
|
definitions := make(map[string]Field, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
definitions[field.Key] = field
|
||||||
|
if field.Required {
|
||||||
|
if item, ok := value[field.Key]; !ok || item == nil || item == "" {
|
||||||
|
return fmt.Errorf("%s.%s 为必填字段", path, field.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, item := range value {
|
||||||
|
field, ok := definitions[key]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s.%s 未在结果格式中定义", path, key)
|
||||||
|
}
|
||||||
|
if err := validateValue(field, item, path+"."+key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateValue(field Field, value interface{}, path string) error {
|
||||||
|
if value == nil {
|
||||||
|
if field.Required {
|
||||||
|
return fmt.Errorf("%s 为必填字段", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch field.Type {
|
||||||
|
case "string", "text", "textarea", "select", "date":
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s 必须是字符串", path)
|
||||||
|
}
|
||||||
|
if field.Type == "select" && len(field.Options) > 0 && !contains(field.Options, text) {
|
||||||
|
return fmt.Errorf("%s 不在允许选项中", path)
|
||||||
|
}
|
||||||
|
case "number", "integer":
|
||||||
|
number, ok := value.(float64)
|
||||||
|
if !ok || (field.Type == "integer" && number != float64(int64(number))) {
|
||||||
|
return fmt.Errorf("%s 必须是%s", path, map[bool]string{true: "整数", false: "数字"}[field.Type == "integer"])
|
||||||
|
}
|
||||||
|
if field.Min != nil && number < *field.Min || field.Max != nil && number > *field.Max {
|
||||||
|
return fmt.Errorf("%s 超出允许范围", path)
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
if _, ok := value.(bool); !ok {
|
||||||
|
return fmt.Errorf("%s 必须是布尔值", path)
|
||||||
|
}
|
||||||
|
case "object":
|
||||||
|
object, ok := value.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s 必须是对象", path)
|
||||||
|
}
|
||||||
|
return validateObject(field.Fields, object, path)
|
||||||
|
case "array", "multiselect":
|
||||||
|
items, ok := value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s 必须是数组", path)
|
||||||
|
}
|
||||||
|
for index, item := range items {
|
||||||
|
if field.Type == "multiselect" && len(field.Options) > 0 && !contains(field.Options, item) {
|
||||||
|
return fmt.Errorf("%s[%d] 不在允许选项中", path, index)
|
||||||
|
}
|
||||||
|
if field.Items != nil {
|
||||||
|
if err := validateValue(*field.Items, item, fmt.Sprintf("%s[%d]", path, index)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(options []any, value interface{}) bool {
|
||||||
|
want := fmt.Sprint(value)
|
||||||
|
for _, option := range options {
|
||||||
|
if strings.EqualFold(fmt.Sprint(option), want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
21
internal/resultcontract/schema_test.go
Normal file
21
internal/resultcontract/schema_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package resultcontract
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateResult(t *testing.T) {
|
||||||
|
schema, err := ParseAndValidate([]byte(`{"fields":[{"key":"recommended_products","name":"成功推荐商品","type":"array","items":{"type":"object","fields":[{"key":"product_id","name":"商品 ID","type":"string","required":true},{"key":"quantity","name":"数量","type":"integer"}]}},{"key":"note","name":"备注","type":"text"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ValidateResult(schema, nil); err != nil {
|
||||||
|
t.Fatalf("empty result should be allowed: %v", err)
|
||||||
|
}
|
||||||
|
valid := map[string]interface{}{"recommended_products": []interface{}{map[string]interface{}{"product_id": "A", "quantity": float64(1)}}}
|
||||||
|
if err := ValidateResult(schema, valid); err != nil {
|
||||||
|
t.Fatalf("valid result rejected: %v", err)
|
||||||
|
}
|
||||||
|
invalid := map[string]interface{}{"recommended_products": []interface{}{map[string]interface{}{"quantity": float64(1)}}}
|
||||||
|
if err := ValidateResult(schema, invalid); err == nil {
|
||||||
|
t.Fatal("missing nested required field should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package run
|
package run
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
@@ -14,7 +15,6 @@ type DetailHeader struct {
|
|||||||
model.SOPRun
|
model.SOPRun
|
||||||
SOPName string `json:"sop_name"`
|
SOPName string `json:"sop_name"`
|
||||||
ScenarioName string `json:"scenario_name"`
|
ScenarioName string `json:"scenario_name"`
|
||||||
Version int `json:"version"`
|
|
||||||
OperatorName string `json:"operator_name"`
|
OperatorName string `json:"operator_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ type DetailEvent struct {
|
|||||||
NodeContent string `json:"node_content"`
|
NodeContent string `json:"node_content"`
|
||||||
NodeConfig datatypes.JSON `json:"-"`
|
NodeConfig datatypes.JSON `json:"-"`
|
||||||
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
|
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
|
||||||
|
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetailFeedback struct {
|
type DetailFeedback struct {
|
||||||
@@ -40,8 +41,8 @@ func (h *Handler) Detail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var header DetailHeader
|
var header DetailHeader
|
||||||
err := h.db.Table("sop_runs r").Select(
|
err := h.db.Table("sop_runs r").Select(
|
||||||
"r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name",
|
"r.*, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name",
|
||||||
).Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").Joins("JOIN users u ON u.id = r.operator_id").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
|
).Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN users u ON u.id = r.operator_id").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
|
||||||
if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
|
if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||||
return
|
return
|
||||||
@@ -52,6 +53,14 @@ func (h *Handler) Detail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
knowledgeCache := map[string]*KnowledgeView{}
|
knowledgeCache := map[string]*KnowledgeView{}
|
||||||
|
answers := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(header.Answers, &answers)
|
||||||
|
derived := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(header.Derived, &derived)
|
||||||
|
input := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(header.Input, &input)
|
||||||
|
context := runtimeContext(input, derived, answers)
|
||||||
|
context["__knowledge_snapshot"] = json.RawMessage(header.KnowledgeSnapshot)
|
||||||
for i := range events {
|
for i := range events {
|
||||||
if events[i].NodeType != "knowledge" {
|
if events[i].NodeType != "knowledge" {
|
||||||
continue
|
continue
|
||||||
@@ -61,9 +70,23 @@ func (h *Handler) Detail(c *gin.Context) {
|
|||||||
events[i].Knowledge = cached
|
events[i].Knowledge = cached
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID)
|
var config knowledgeNodeConfig
|
||||||
|
_ = json.Unmarshal(events[i].NodeConfig, &config)
|
||||||
|
if config.KnowledgeSelector != nil {
|
||||||
|
var node model.SOPNode
|
||||||
|
if err := h.db.Where("sop_version_id = ? AND node_key = ?", header.SOPVersionID, events[i].NodeKey).First(&node).Error; err == nil {
|
||||||
|
outputs, loadErr := loadKnowledgeOutputs(h.db, node, principal.TenantID, context, *config.KnowledgeSelector)
|
||||||
|
if loadErr != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录知识快照不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events[i].Outputs = outputs
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID, answers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的知识卡版本不存在")
|
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的历史知识内容不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
events[i].Knowledge = &knowledge
|
events[i].Knowledge = &knowledge
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func matchRule(rule map[string]interface{}, answers map[string]interface{}) (boo
|
|||||||
if field == "" || operator == "" {
|
if field == "" || operator == "" {
|
||||||
return false, fmt.Errorf("condition field and operator are required")
|
return false, fmt.Errorf("condition field and operator are required")
|
||||||
}
|
}
|
||||||
actual, exists := answers[field]
|
actual, exists := lookupContextValue(answers, field)
|
||||||
expected := rule["value"]
|
expected := rule["value"]
|
||||||
switch operator {
|
switch operator {
|
||||||
case "exists":
|
case "exists":
|
||||||
@@ -105,6 +105,35 @@ func matchRule(rule map[string]interface{}, answers map[string]interface{}) (boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupContextValue accepts the public namespaced form (input.foo/derived.foo)
|
||||||
|
// and the legacy bare form used by SOP edge conditions.
|
||||||
|
func lookupContextValue(values map[string]interface{}, field string) (interface{}, bool) {
|
||||||
|
if value, ok := values[field]; ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"input.", "derived.", "form."} {
|
||||||
|
if strings.HasPrefix(field, prefix) {
|
||||||
|
key := strings.TrimPrefix(field, prefix)
|
||||||
|
if namespace, ok := values[strings.TrimSuffix(prefix, ".")].(map[string]interface{}); ok {
|
||||||
|
value, exists := namespace[key]
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
value, ok := values[key]
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeContext(input, derived, form map[string]interface{}) map[string]interface{} {
|
||||||
|
context := mergeValues(input, derived)
|
||||||
|
context = mergeValues(context, form)
|
||||||
|
context["input"] = input
|
||||||
|
context["derived"] = derived
|
||||||
|
context["form"] = form
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeValue(value interface{}) interface{} {
|
func normalizeValue(value interface{}) interface{} {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case json.Number:
|
case json.Number:
|
||||||
|
|||||||
50
internal/run/generic_scenario_test.go
Normal file
50
internal/run/generic_scenario_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenericRuntimeSupportsDifferentDomains(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields []model.ScenarioField
|
||||||
|
input map[string]interface{}
|
||||||
|
rules []model.ScenarioRule
|
||||||
|
wantInput map[string]interface{}
|
||||||
|
wantOutput interface{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pet order",
|
||||||
|
fields: []model.ScenarioField{{FieldKey: "tags", SourcePath: "order.items[*].symptom_tags[*]"}},
|
||||||
|
input: map[string]interface{}{"order": map[string]interface{}{"items": []interface{}{map[string]interface{}{"symptom_tags": []interface{}{"soft_stool"}}}}},
|
||||||
|
rules: []model.ScenarioRule{{RuleKey: "pet", Condition: datatypes.JSON([]byte(`{"field":"input.tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched","value_from":"input.tags"}]`))}},
|
||||||
|
wantInput: map[string]interface{}{"tags": []interface{}{"soft_stool"}}, wantOutput: []interface{}{"soft_stool"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "course recommendation",
|
||||||
|
fields: []model.ScenarioField{{FieldKey: "goals", SourcePath: "learner.goals[*]"}},
|
||||||
|
input: map[string]interface{}{"learner": map[string]interface{}{"goals": []interface{}{"presentation"}}},
|
||||||
|
rules: []model.ScenarioRule{{RuleKey: "course", Condition: datatypes.JSON([]byte(`{"field":"input.goals","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched","value_from":"input.goals"}]`))}},
|
||||||
|
wantInput: map[string]interface{}{"goals": []interface{}{"presentation"}}, wantOutput: []interface{}{"presentation"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
mapped := mapScenarioInput(test.fields, test.input)
|
||||||
|
if !reflect.DeepEqual(mapped, test.wantInput) {
|
||||||
|
t.Fatalf("mapped=%#v", mapped)
|
||||||
|
}
|
||||||
|
derived, _, err := applyScenarioRules(test.rules, mapped)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(derived["matched"], test.wantOutput) {
|
||||||
|
t.Fatalf("derived=%#v", derived)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import (
|
|||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -27,7 +29,6 @@ type listItem struct {
|
|||||||
model.SOPRun
|
model.SOPRun
|
||||||
SOPName string `json:"sop_name"`
|
SOPName string `json:"sop_name"`
|
||||||
ScenarioName string `json:"scenario_name"`
|
ScenarioName string `json:"scenario_name"`
|
||||||
Version int `json:"version"`
|
|
||||||
OperatorName string `json:"operator_name"`
|
OperatorName string `json:"operator_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ func NewHandler(db *gorm.DB) *Handler {
|
|||||||
return &Handler{db: db}
|
return &Handler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) PublishedSOPs(c *gin.Context) {
|
func (h *Handler) AvailableSOPs(c *gin.Context) {
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
type item struct {
|
type item struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
@@ -48,14 +49,13 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ScenarioID uint64 `json:"scenario_id"`
|
ScenarioID uint64 `json:"scenario_id"`
|
||||||
ScenarioName string `json:"scenario_name"`
|
ScenarioName string `json:"scenario_name"`
|
||||||
Version int `json:"version"`
|
|
||||||
}
|
}
|
||||||
items := make([]item, 0)
|
items := make([]item, 0)
|
||||||
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name, sv.version").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
|
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
|
||||||
query = access.ScopeScenarios(query, p, "sc")
|
query = access.ScopeScenarios(query, p, "sc")
|
||||||
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
|
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败")
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可用 SOP 失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
@@ -65,26 +65,89 @@ func (h *Handler) Start(c *gin.Context) {
|
|||||||
p, _ := auth.PrincipalFromContext(c)
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
var input struct {
|
var input struct {
|
||||||
SOPID uint64 `json:"sop_id" binding:"required"`
|
SOPID uint64 `json:"sop_id" binding:"required"`
|
||||||
|
Input map[string]interface{} `json:"input"`
|
||||||
|
ExternalRef string `json:"external_ref"`
|
||||||
|
InitialValues map[string]interface{} `json:"initial_values"`
|
||||||
|
InitialAnswers map[string]interface{} `json:"initial_answers"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !access.CanViewSOP(h.db, p, input.SOPID) {
|
if !access.CanViewSOP(h.db, p, input.SOPID) {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布 SOP")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateExternalRef(input.ExternalRef); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_EXTERNAL_REF", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.ExternalRef != "" {
|
||||||
|
var existing model.SOPRun
|
||||||
|
if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", p.TenantID, input.SOPID, input.ExternalRef).First(&existing).Error; err == nil {
|
||||||
|
h.respondRun(c, existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
var version model.SOPVersion
|
var version model.SOPVersion
|
||||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布版本")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON([]byte(`{}`)), Result: "", StartedAt: time.Now()}
|
fields := make([]model.ScenarioField, 0)
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", input.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sopItem model.SOP
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", input.SOPID, p.TenantID).First(&sopItem).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Input == nil {
|
||||||
|
input.Input = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if input.InitialValues == nil {
|
||||||
|
input.InitialValues = input.InitialAnswers
|
||||||
|
}
|
||||||
|
if input.InitialValues == nil {
|
||||||
|
input.InitialValues = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
normalizedInput := mergeValues(mapScenarioInput(fields, input.Input), input.InitialValues)
|
||||||
|
if err := validateInitialAnswers(fields, normalizedInput); err != nil {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INITIAL_ANSWERS", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
initialAnswers, marshalErr := json.Marshal(normalizedInput)
|
||||||
|
if marshalErr != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_INITIAL_ANSWERS", "传入字段格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
derived, matchedRules, err := deriveForScenario(h.db, p.TenantID, sopItem.ScenarioID, normalizedInput)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
derivedRaw, _ := json.Marshal(derived)
|
||||||
|
knowledgeRaw, err := snapshotKnowledge(h.db, p.TenantID, sopItem.ScenarioID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
externalRef := input.ExternalRef
|
||||||
|
if externalRef == "" {
|
||||||
|
externalRef = "run-" + uuid.NewString()
|
||||||
|
}
|
||||||
|
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), Result: "", StartedAt: time.Now()}
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(&run).Error; err != nil {
|
if err := tx.Create(&run).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON([]byte(`{}`))}).Error
|
payload, err := json.Marshal(gin.H{"source": "scenario_input", "mapped_field_keys": sortedKeys(normalizedInput), "matched_rule_keys": matchedRules})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败")
|
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败")
|
||||||
@@ -118,7 +181,6 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
query := scopeRuns(h.db.Table("sop_runs r").
|
query := scopeRuns(h.db.Table("sop_runs r").
|
||||||
Joins("JOIN sops s ON s.id = r.sop_id").
|
Joins("JOIN sops s ON s.id = r.sop_id").
|
||||||
Joins("JOIN scenarios sc ON sc.id = s.scenario_id").
|
Joins("JOIN scenarios sc ON sc.id = s.scenario_id").
|
||||||
Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").
|
|
||||||
Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
|
Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
|
||||||
if status := c.Query("status"); status != "" {
|
if status := c.Query("status"); status != "" {
|
||||||
query = query.Where("r.status = ?", status)
|
query = query.Where("r.status = ?", status)
|
||||||
@@ -144,7 +206,10 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
items := make([]listItem, 0)
|
items := make([]listItem, 0)
|
||||||
if err := query.Select("r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
// The run payload contains several large JSON snapshots. The history list only
|
||||||
|
// needs scalar metadata; excluding blobs keeps MySQL's sort buffer bounded.
|
||||||
|
listColumns := "r.id, r.created_at, r.updated_at, r.tenant_id, r.sop_id, r.sop_version_id, r.operator_id, r.external_ref, r.current_node_key, r.status, r.result, r.started_at, r.completed_at, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name"
|
||||||
|
if err := query.Select(listColumns).Order("r.created_at DESC, r.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,6 +285,9 @@ func (h *Handler) Answer(c *gin.Context) {
|
|||||||
for key, value := range input.Answers {
|
for key, value := range input.Answers {
|
||||||
answers[key] = value
|
answers[key] = value
|
||||||
}
|
}
|
||||||
|
if err := validateKnowledgeSelections(currentNode, updated, answers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var edges []model.SOPEdge
|
var edges []model.SOPEdge
|
||||||
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
|
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -283,6 +351,15 @@ func sortEdges(edges []model.SOPEdge) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortedKeys(values map[string]interface{}) []string {
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func defaultCondition(raw []byte) bool {
|
func defaultCondition(raw []byte) bool {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return true
|
return true
|
||||||
@@ -302,10 +379,11 @@ func (h *Handler) Finish(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var input struct {
|
var input struct {
|
||||||
Result string `json:"result" binding:"required,oneof=manual"`
|
Result string `json:"result"`
|
||||||
|
FinalResult json.RawMessage `json:"final_result"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "执行结果格式不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var run model.SOPRun
|
var run model.SOPRun
|
||||||
@@ -317,20 +395,51 @@ func (h *Handler) Finish(c *gin.Context) {
|
|||||||
if !canOperateRun(p, run) {
|
if !canOperateRun(p, run) {
|
||||||
return gorm.ErrRecordNotFound
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
if run.Status != "running" {
|
if run.Status != "running" && run.Status != "completed" {
|
||||||
return runCompletedErr
|
return runCompletedErr
|
||||||
}
|
}
|
||||||
now := time.Now()
|
finalResult, err := parseFinalResult(input.FinalResult)
|
||||||
payload, _ := json.Marshal(input)
|
if err != nil {
|
||||||
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}).Error; err != nil {
|
var scenario model.Scenario
|
||||||
|
if err := tx.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
schema, err := resultcontract.ParseAndValidate(scenario.ResultSchema)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := resultcontract.ValidateResult(schema, finalResult); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
finalRaw, _ := json.Marshal(finalResult)
|
||||||
|
if input.Result == "" {
|
||||||
|
input.Result = run.Result
|
||||||
|
if input.Result == "" {
|
||||||
|
input.Result = "manual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
completedAt := run.CompletedAt
|
||||||
|
if completedAt == nil {
|
||||||
|
completedAt = &now
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(input)
|
||||||
|
action := "finish"
|
||||||
|
if run.Status == "completed" {
|
||||||
|
action = "final_result"
|
||||||
|
}
|
||||||
|
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: action, Payload: datatypes.JSON(payload)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
run.Status = "completed"
|
run.Status = "completed"
|
||||||
run.Result = input.Result
|
run.Result = input.Result
|
||||||
run.CompletedAt = &now
|
run.FinalResult = datatypes.JSON(finalRaw)
|
||||||
|
run.CompletedAt = completedAt
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -342,7 +451,7 @@ func (h *Handler) Finish(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败")
|
response.Error(c, http.StatusUnprocessableEntity, "FINISH_FAILED", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
|
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
|
||||||
@@ -387,17 +496,39 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
|
|||||||
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
nodeView, err := h.nodeView(h.db, node, item.TenantID)
|
answers := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(item.Answers, &answers)
|
||||||
|
derived := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(item.Derived, &derived)
|
||||||
|
input := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(item.Input, &input)
|
||||||
|
context := runtimeContext(input, derived, answers)
|
||||||
|
context["__knowledge_snapshot"] = json.RawMessage(item.KnowledgeSnapshot)
|
||||||
|
nodeView, err := h.nodeView(h.db, node, item.TenantID, context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的知识卡版本不存在")
|
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的历史知识内容不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
outputs, err := buildScenarioOutputs(h.db, item, nodeView.Outputs)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw, marshalErr := json.Marshal(outputs); marshalErr == nil {
|
||||||
|
item.Outputs = datatypes.JSON(raw)
|
||||||
|
_ = h.db.Model(&model.SOPRun{}).Where("id = ?", item.ID).Update("outputs", item.Outputs).Error
|
||||||
|
}
|
||||||
fields := make([]model.ScenarioField, 0)
|
fields := make([]model.ScenarioField, 0)
|
||||||
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields})
|
var scenario model.Scenario
|
||||||
|
if err := h.db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", item.SOPID, item.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景结果格式失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields, "outputs": outputs, "result_schema": scenario.ResultSchema})
|
||||||
}
|
}
|
||||||
|
|
||||||
func runID(c *gin.Context) (uint64, bool) {
|
func runID(c *gin.Context) (uint64, bool) {
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ package run
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
knowledgecontent "git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var knowledgePlaceholderPattern = regexp.MustCompile(`\{\{\s*(?:input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
|
||||||
|
|
||||||
type KnowledgeView struct {
|
type KnowledgeView struct {
|
||||||
CardID uint64 `json:"card_id"`
|
CardID uint64 `json:"card_id"`
|
||||||
CardVersionID uint64 `json:"card_version_id"`
|
CardVersionID uint64 `json:"card_version_id"`
|
||||||
@@ -15,24 +22,153 @@ type KnowledgeView struct {
|
|||||||
StandardCopy string `json:"standard_copy"`
|
StandardCopy string `json:"standard_copy"`
|
||||||
ForbiddenCopy string `json:"forbidden_copy"`
|
ForbiddenCopy string `json:"forbidden_copy"`
|
||||||
RiskNote string `json:"risk_note"`
|
RiskNote string `json:"risk_note"`
|
||||||
|
Copies []KnowledgeCopy `json:"copies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeCopy struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeView struct {
|
type NodeView struct {
|
||||||
model.SOPNode
|
NodeKey string `json:"node_key"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Config json.RawMessage `json:"config,omitempty"`
|
||||||
|
Fields []PublicFieldView `json:"fields,omitempty"`
|
||||||
|
Presentation *NodePresentation `json:"presentation,omitempty"`
|
||||||
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
||||||
|
Outputs []KnowledgeGroup `json:"outputs"`
|
||||||
|
Collection *KnowledgeCollectionView `json:"collection,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicFieldView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Options json.RawMessage `json:"options"`
|
||||||
|
Validation json.RawMessage `json:"validation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeGroup struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Relations map[string][]KnowledgeItemView `json:"relations"`
|
||||||
|
Suggested bool `json:"suggested,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeItemView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type knowledgeNodeConfig struct {
|
type knowledgeNodeConfig struct {
|
||||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
||||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
||||||
|
KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"`
|
||||||
|
KnowledgeCollection *knowledgeCollectionConfig `json:"knowledge_collection"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (NodeView, error) {
|
type knowledgeCollectionConfig struct {
|
||||||
view := NodeView{SOPNode: node}
|
ContextFieldKeys []string `json:"context_field_keys"`
|
||||||
|
ContextTitle string `json:"context_title"`
|
||||||
|
ContextHint string `json:"context_hint"`
|
||||||
|
SelectionTitle string `json:"selection_title"`
|
||||||
|
SelectionHint string `json:"selection_hint"`
|
||||||
|
Steps []knowledgeCollectionStep `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type knowledgeCollectionStep struct {
|
||||||
|
FieldKey string `json:"field_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Root bool `json:"root"`
|
||||||
|
CandidateScope string `json:"candidate_scope"`
|
||||||
|
KnowledgeTypes []string `json:"knowledge_types"`
|
||||||
|
FromField string `json:"from_field"`
|
||||||
|
RelationType string `json:"relation_type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Multiple bool `json:"multiple"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeCollectionView struct {
|
||||||
|
ContextFields []PublicFieldView `json:"context_fields"`
|
||||||
|
ContextTitle string `json:"context_title"`
|
||||||
|
ContextHint string `json:"context_hint"`
|
||||||
|
SelectionTitle string `json:"selection_title"`
|
||||||
|
SelectionHint string `json:"selection_hint"`
|
||||||
|
Steps []KnowledgeCollectionStepView `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeCollectionStepView struct {
|
||||||
|
FieldKey string `json:"field_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Multiple bool `json:"multiple"`
|
||||||
|
FromField string `json:"from_field,omitempty"`
|
||||||
|
Options []KnowledgeCollectionOption `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeCollectionOption struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Parents []string `json:"parents,omitempty"`
|
||||||
|
Suggested bool `json:"suggested,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type knowledgeSelector struct {
|
||||||
|
DerivedField string `json:"derived_field"`
|
||||||
|
AnswerField string `json:"answer_field"`
|
||||||
|
CandidateScope string `json:"candidate_scope"`
|
||||||
|
KnowledgeTypes []string `json:"knowledge_types"`
|
||||||
|
KnowledgeKeys []string `json:"knowledge_keys"`
|
||||||
|
RelationTypes []string `json:"relation_types"`
|
||||||
|
RelationLabels map[string]string `json:"relation_labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64, answers map[string]interface{}) (NodeView, error) {
|
||||||
|
view := NodeView{NodeKey: node.NodeKey, Type: node.Type, Title: node.Title, Content: node.Content, Config: json.RawMessage(node.Config)}
|
||||||
|
if node.Type == "start" {
|
||||||
|
presentation, err := loadStartPresentation(db, node, tenantID, answers)
|
||||||
|
if err != nil {
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
|
view.Presentation = presentation
|
||||||
|
}
|
||||||
|
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
|
||||||
|
fields, err := loadPublicNodeFields(db, node, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
|
view.Fields = fields
|
||||||
|
}
|
||||||
if node.Type != "knowledge" {
|
if node.Type != "knowledge" {
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
knowledge, err := loadKnowledgeView(db, node, tenantID)
|
var config knowledgeNodeConfig
|
||||||
|
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
|
if config.KnowledgeSelector != nil {
|
||||||
|
outputs, err := loadKnowledgeOutputs(db, node, tenantID, answers, *config.KnowledgeSelector)
|
||||||
|
view.Outputs = outputs
|
||||||
|
if err != nil {
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
|
if config.KnowledgeCollection != nil {
|
||||||
|
collection, err := loadKnowledgeCollection(db, node, tenantID, *config.KnowledgeCollection, outputs, answers)
|
||||||
|
view.Collection = &collection
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
knowledge, err := loadKnowledgeView(db, node, tenantID, answers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return view, err
|
return view, err
|
||||||
}
|
}
|
||||||
@@ -40,7 +176,366 @@ func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (No
|
|||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (KnowledgeView, error) {
|
func loadKnowledgeCollection(db *gorm.DB, node model.SOPNode, tenantID uint64, config knowledgeCollectionConfig, roots []KnowledgeGroup, context map[string]interface{}) (KnowledgeCollectionView, error) {
|
||||||
|
fields, err := loadPublicFieldsByKeys(db, node, tenantID, config.ContextFieldKeys)
|
||||||
|
if err != nil {
|
||||||
|
return KnowledgeCollectionView{}, err
|
||||||
|
}
|
||||||
|
view := KnowledgeCollectionView{ContextFields: fields, ContextTitle: config.ContextTitle, ContextHint: config.ContextHint, SelectionTitle: config.SelectionTitle, SelectionHint: config.SelectionHint, Steps: make([]KnowledgeCollectionStepView, 0, len(config.Steps))}
|
||||||
|
childrenByParent := map[string]map[string][]string{}
|
||||||
|
allItems := make([]model.KnowledgeItem, 0)
|
||||||
|
if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 {
|
||||||
|
snapshot, parseErr := parseKnowledgeSnapshot(raw)
|
||||||
|
if parseErr != nil {
|
||||||
|
return KnowledgeCollectionView{}, parseErr
|
||||||
|
}
|
||||||
|
byID := map[uint64]model.KnowledgeItem{}
|
||||||
|
for _, item := range snapshot.Items {
|
||||||
|
byID[item.ID] = item
|
||||||
|
if item.Status == "active" {
|
||||||
|
allItems = append(allItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, relation := range snapshot.Relations {
|
||||||
|
from, fromOK := byID[relation.FromKnowledgeID]
|
||||||
|
to, toOK := byID[relation.ToKnowledgeID]
|
||||||
|
if !fromOK || !toOK || from.Status != "active" || to.Status != "active" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if childrenByParent[from.Name] == nil {
|
||||||
|
childrenByParent[from.Name] = map[string][]string{}
|
||||||
|
}
|
||||||
|
childrenByParent[from.Name][relation.RelationType] = appendUnique(childrenByParent[from.Name][relation.RelationType], to.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suggestedRoots := make(map[string]bool, len(roots))
|
||||||
|
rootOptions := make([]KnowledgeCollectionOption, 0, len(roots))
|
||||||
|
for _, root := range roots {
|
||||||
|
suggestedRoots[root.Name] = root.Suggested
|
||||||
|
rootOptions = append(rootOptions, KnowledgeCollectionOption{Value: root.Name, Label: root.Name, Suggested: root.Suggested})
|
||||||
|
}
|
||||||
|
for _, step := range config.Steps {
|
||||||
|
item := KnowledgeCollectionStepView{FieldKey: step.FieldKey, Name: step.Name, Required: step.Required, Multiple: step.Multiple, FromField: step.FromField, Options: []KnowledgeCollectionOption{}}
|
||||||
|
if step.Root {
|
||||||
|
item.Options = append(item.Options, rootOptions...)
|
||||||
|
if step.CandidateScope == "all" {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, option := range item.Options {
|
||||||
|
seen[option.Value] = true
|
||||||
|
}
|
||||||
|
for _, knowledgeItem := range allItems {
|
||||||
|
if seen[knowledgeItem.Name] || (len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, knowledgeItem.Type)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.Options = append(item.Options, KnowledgeCollectionOption{Value: knowledgeItem.Name, Label: knowledgeItem.Name, Suggested: suggestedRoots[knowledgeItem.Name]})
|
||||||
|
}
|
||||||
|
sort.SliceStable(item.Options, func(i, j int) bool {
|
||||||
|
if item.Options[i].Suggested != item.Options[j].Suggested {
|
||||||
|
return item.Options[i].Suggested
|
||||||
|
}
|
||||||
|
return item.Options[i].Label < item.Options[j].Label
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seen := map[string]*KnowledgeCollectionOption{}
|
||||||
|
var parentStep *KnowledgeCollectionStepView
|
||||||
|
for index := range view.Steps {
|
||||||
|
if view.Steps[index].FieldKey == step.FromField {
|
||||||
|
parentStep = &view.Steps[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parentStep != nil {
|
||||||
|
for _, parent := range parentStep.Options {
|
||||||
|
for _, childName := range childrenByParent[parent.Value][step.RelationType] {
|
||||||
|
option := seen[childName]
|
||||||
|
if option == nil {
|
||||||
|
option = &KnowledgeCollectionOption{Value: childName, Label: childName}
|
||||||
|
seen[childName] = option
|
||||||
|
}
|
||||||
|
option.Parents = appendUnique(option.Parents, parent.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, option := range seen {
|
||||||
|
item.Options = append(item.Options, *option)
|
||||||
|
}
|
||||||
|
sort.Slice(item.Options, func(i, j int) bool { return item.Options[i].Label < item.Options[j].Label })
|
||||||
|
}
|
||||||
|
view.Steps = append(view.Steps, item)
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPublicFieldsByKeys(db *gorm.DB, node model.SOPNode, tenantID uint64, keys []string) ([]PublicFieldView, error) {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return []PublicFieldView{}, nil
|
||||||
|
}
|
||||||
|
var fields []model.ScenarioField
|
||||||
|
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).Find(&fields).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byKey := map[string]model.ScenarioField{}
|
||||||
|
for _, field := range fields {
|
||||||
|
byKey[field.FieldKey] = field
|
||||||
|
}
|
||||||
|
result := make([]PublicFieldView, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if field, ok := byKey[key]; ok {
|
||||||
|
result = append(result, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required, Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUnique(values []string, value string) []string {
|
||||||
|
for _, existing := range values {
|
||||||
|
if existing == value {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]PublicFieldView, error) {
|
||||||
|
var config answerNodeConfig
|
||||||
|
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys := config.FieldKeys
|
||||||
|
if config.FieldKey != "" {
|
||||||
|
keys = []string{config.FieldKey}
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var fields []model.ScenarioField
|
||||||
|
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).Find(&fields).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byKey := make(map[string]model.ScenarioField, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
byKey[field.FieldKey] = field
|
||||||
|
}
|
||||||
|
views := make([]PublicFieldView, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
field, ok := byKey[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
views = append(views, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required || config.Required || containsString(config.RequiredFieldKeys, key), Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKnowledgeOutputs(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
|
||||||
|
var sopRow struct{ ScenarioID uint64 }
|
||||||
|
if err := db.Table("sop_nodes n").Select("s.scenario_id").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("n.id = ? AND n.tenant_id = ?", node.ID, tenantID).Scan(&sopRow).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return loadKnowledgeOutputsForScenario(db, sopRow.ScenarioID, tenantID, context, selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKnowledgeOutputsForScenario(db *gorm.DB, scenarioID, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
|
||||||
|
keys := append([]string{}, selector.KnowledgeKeys...)
|
||||||
|
if selector.DerivedField != "" {
|
||||||
|
value, _ := lookupContextValue(context, "derived."+selector.DerivedField)
|
||||||
|
keys = append(keys, stringSlice(value)...)
|
||||||
|
}
|
||||||
|
if selector.AnswerField != "" {
|
||||||
|
value, _ := lookupContextValue(context, "form."+selector.AnswerField)
|
||||||
|
keys = append(keys, stringSlice(value)...)
|
||||||
|
}
|
||||||
|
if len(keys) == 0 && selector.CandidateScope != "all" {
|
||||||
|
return []KnowledgeGroup{}, nil
|
||||||
|
}
|
||||||
|
keySet := make(map[string]bool, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
keySet[key] = true
|
||||||
|
}
|
||||||
|
items := make([]model.KnowledgeItem, 0)
|
||||||
|
relations := make([]model.KnowledgeRelation, 0)
|
||||||
|
snapshotTargets := map[uint64]model.KnowledgeItem{}
|
||||||
|
if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 {
|
||||||
|
snapshot, err := parseKnowledgeSnapshot(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range snapshot.Items {
|
||||||
|
snapshotTargets[item.ID] = item
|
||||||
|
if item.Status != "active" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if selector.CandidateScope != "all" && len(keys) > 0 && !knowledgeCandidateMatches(keySet, item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(selector.KnowledgeTypes) > 0 && !containsString(selector.KnowledgeTypes, item.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
for _, relation := range snapshot.Relations {
|
||||||
|
if len(selector.RelationTypes) > 0 && !containsString(selector.RelationTypes, relation.RelationType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relations = append(relations, relation)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active")
|
||||||
|
if selector.CandidateScope != "all" && len(keys) > 0 {
|
||||||
|
query = query.Where("item_key IN ? OR name IN ?", keys, keys)
|
||||||
|
}
|
||||||
|
if len(selector.KnowledgeTypes) > 0 {
|
||||||
|
query = query.Where("type IN ?", selector.KnowledgeTypes)
|
||||||
|
}
|
||||||
|
if err := query.Order("sort_order, id").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids := make([]uint64, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if selector.CandidateScope == "all" || len(keySet) == 0 || knowledgeCandidateMatches(keySet, item) {
|
||||||
|
ids = append(ids, item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(relations) == 0 && len(ids) > 0 {
|
||||||
|
rq := db.Where("tenant_id = ? AND scenario_id = ? AND from_knowledge_id IN ?", tenantID, scenarioID, ids)
|
||||||
|
if len(selector.RelationTypes) > 0 {
|
||||||
|
rq = rq.Where("relation_type IN ?", selector.RelationTypes)
|
||||||
|
}
|
||||||
|
if err := rq.Order("sort_order,id").Find(&relations).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targetIDs := make([]uint64, 0, len(relations))
|
||||||
|
for _, rel := range relations {
|
||||||
|
targetIDs = append(targetIDs, rel.ToKnowledgeID)
|
||||||
|
}
|
||||||
|
targets := make([]model.KnowledgeItem, 0)
|
||||||
|
if len(snapshotTargets) > 0 {
|
||||||
|
for _, id := range targetIDs {
|
||||||
|
if target, ok := snapshotTargets[id]; ok && target.Status == "active" {
|
||||||
|
targets = append(targets, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if len(targetIDs) > 0 {
|
||||||
|
if err := db.Where("tenant_id = ? AND id IN ? AND status = ?", tenantID, targetIDs, "active").Find(&targets).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targetMap := map[uint64]model.KnowledgeItem{}
|
||||||
|
for _, target := range targets {
|
||||||
|
targetMap[target.ID] = target
|
||||||
|
}
|
||||||
|
relationMap := map[uint64]map[string][]KnowledgeItemView{}
|
||||||
|
for _, rel := range relations {
|
||||||
|
matched, err := matchCondition(json.RawMessage(rel.Condition), context)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("知识关系 %d 条件不正确: %w", rel.ID, err)
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target, ok := targetMap[rel.ToKnowledgeID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if relationMap[rel.FromKnowledgeID] == nil {
|
||||||
|
relationMap[rel.FromKnowledgeID] = map[string][]KnowledgeItemView{}
|
||||||
|
}
|
||||||
|
relationMap[rel.FromKnowledgeID][rel.RelationType] = append(relationMap[rel.FromKnowledgeID][rel.RelationType], KnowledgeItemView{Key: target.ItemKey, Name: target.Name, Type: target.Type, Content: renderKnowledgeContent(target.Content, context)})
|
||||||
|
}
|
||||||
|
outputs := make([]KnowledgeGroup, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
outputs = append(outputs, KnowledgeGroup{Key: item.ItemKey, Name: item.Name, Type: item.Type, Content: renderKnowledgeContent(item.Content, context), Relations: relationMap[item.ID], Suggested: knowledgeCandidateMatches(keySet, item)})
|
||||||
|
}
|
||||||
|
return outputs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeCandidateMatches(candidates map[string]bool, item model.KnowledgeItem) bool {
|
||||||
|
return candidates[item.ItemKey] || candidates[item.Name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, target string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderKnowledgeContent(raw []byte, context map[string]interface{}) json.RawMessage {
|
||||||
|
var value interface{}
|
||||||
|
if json.Unmarshal(raw, &value) != nil {
|
||||||
|
return json.RawMessage(raw)
|
||||||
|
}
|
||||||
|
value = renderKnowledgeValue(value, context)
|
||||||
|
rendered, _ := json.Marshal(value)
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
func renderKnowledgeValue(value interface{}, context map[string]interface{}) interface{} {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return knowledgePlaceholderPattern.ReplaceAllStringFunc(typed, func(token string) string {
|
||||||
|
match := knowledgePlaceholderPattern.FindStringSubmatch(token)
|
||||||
|
if len(match) != 2 {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
resolved, ok := lookupContextValue(context, tokenNamespaceKey(token, match[1]))
|
||||||
|
if !ok || resolved == nil {
|
||||||
|
return "未提供"
|
||||||
|
}
|
||||||
|
if values, ok := resolved.([]interface{}); ok {
|
||||||
|
parts := make([]string, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
parts = append(parts, fmt.Sprint(item))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "、")
|
||||||
|
}
|
||||||
|
return fmt.Sprint(resolved)
|
||||||
|
})
|
||||||
|
case []interface{}:
|
||||||
|
for index, item := range typed {
|
||||||
|
typed[index] = renderKnowledgeValue(item, context)
|
||||||
|
}
|
||||||
|
return typed
|
||||||
|
case map[string]interface{}:
|
||||||
|
for key, item := range typed {
|
||||||
|
typed[key] = renderKnowledgeValue(item, context)
|
||||||
|
}
|
||||||
|
return typed
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenNamespaceKey(token, key string) string {
|
||||||
|
trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(token, "{{"), "}}"))
|
||||||
|
if strings.Contains(trimmed, ".") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSlice(value interface{}) []string {
|
||||||
|
result := []string{}
|
||||||
|
switch values := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range values {
|
||||||
|
if text, ok := item.(string); ok {
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
return values
|
||||||
|
case string:
|
||||||
|
return []string{values}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64, answers map[string]interface{}) (KnowledgeView, error) {
|
||||||
var config knowledgeNodeConfig
|
var config knowledgeNodeConfig
|
||||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||||
return KnowledgeView{}, err
|
return KnowledgeView{}, err
|
||||||
@@ -61,16 +556,21 @@ func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (Knowle
|
|||||||
if err := query.First(&version).Error; err != nil {
|
if err := query.First(&version).Error; err != nil {
|
||||||
return KnowledgeView{}, err
|
return KnowledgeView{}, err
|
||||||
}
|
}
|
||||||
var content struct {
|
content, err := knowledgecontent.ParseContent(version.Content)
|
||||||
StandardCopy string `json:"standard_copy"`
|
if err != nil {
|
||||||
ForbiddenCopy string `json:"forbidden_copy"`
|
|
||||||
RiskNote string `json:"risk_note"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(version.Content, &content); err != nil {
|
|
||||||
return KnowledgeView{}, err
|
return KnowledgeView{}, err
|
||||||
}
|
}
|
||||||
|
rendered := knowledgecontent.Render(content, answers)
|
||||||
|
copies := make([]KnowledgeCopy, 0, len(rendered))
|
||||||
|
for _, copy := range rendered {
|
||||||
|
copies = append(copies, KnowledgeCopy{ID: copy.ID, Title: copy.Title, Content: copy.Content})
|
||||||
|
}
|
||||||
|
standardCopy := ""
|
||||||
|
if len(copies) > 0 {
|
||||||
|
standardCopy = copies[0].Content
|
||||||
|
}
|
||||||
return KnowledgeView{
|
return KnowledgeView{
|
||||||
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title,
|
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title,
|
||||||
StandardCopy: content.StandardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote,
|
StandardCopy: standardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote, Copies: copies,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
79
internal/run/knowledge_collection.go
Normal file
79
internal/run/knowledge_collection.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateKnowledgeSelections(node model.SOPNode, run model.SOPRun, answers map[string]interface{}) error {
|
||||||
|
if node.Type != "knowledge" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var config knowledgeNodeConfig
|
||||||
|
if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshot, err := parseKnowledgeSnapshot(run.KnowledgeSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
itemsByID := map[uint64]model.KnowledgeItem{}
|
||||||
|
itemsByName := map[string]model.KnowledgeItem{}
|
||||||
|
for _, item := range snapshot.Items {
|
||||||
|
if item.Status == "active" {
|
||||||
|
itemsByID[item.ID] = item
|
||||||
|
itemsByName[item.Name] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relations := map[uint64]map[string]map[uint64]bool{}
|
||||||
|
for _, rel := range snapshot.Relations {
|
||||||
|
if relations[rel.FromKnowledgeID] == nil {
|
||||||
|
relations[rel.FromKnowledgeID] = map[string]map[uint64]bool{}
|
||||||
|
}
|
||||||
|
if relations[rel.FromKnowledgeID][rel.RelationType] == nil {
|
||||||
|
relations[rel.FromKnowledgeID][rel.RelationType] = map[uint64]bool{}
|
||||||
|
}
|
||||||
|
relations[rel.FromKnowledgeID][rel.RelationType][rel.ToKnowledgeID] = true
|
||||||
|
}
|
||||||
|
selected := map[string][]model.KnowledgeItem{}
|
||||||
|
derived := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Derived, &derived)
|
||||||
|
rootCandidates := map[string]bool{}
|
||||||
|
if config.KnowledgeSelector != nil {
|
||||||
|
for _, value := range stringSlice(derived[config.KnowledgeSelector.DerivedField]) {
|
||||||
|
rootCandidates[value] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, step := range config.KnowledgeCollection.Steps {
|
||||||
|
values, _ := stringValues(answers[step.FieldKey])
|
||||||
|
for _, value := range values {
|
||||||
|
item, ok := itemsByName[value]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s包含不存在的知识选项:%s", step.Name, value)
|
||||||
|
}
|
||||||
|
if step.Root {
|
||||||
|
if len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, item.Type) {
|
||||||
|
return fmt.Errorf("%s的知识类型不正确:%s", step.Name, value)
|
||||||
|
}
|
||||||
|
if step.CandidateScope != "all" && !rootCandidates[item.Name] && !rootCandidates[item.ItemKey] {
|
||||||
|
return fmt.Errorf("%s不属于本次订单推断结果:%s", step.Name, value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
valid := false
|
||||||
|
for _, parent := range selected[step.FromField] {
|
||||||
|
if relations[parent.ID][step.RelationType][item.ID] {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return fmt.Errorf("%s与已选择的上级知识不关联:%s", step.Name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected[step.FieldKey] = append(selected[step.FieldKey], item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
62
internal/run/knowledge_collection_test.go
Normal file
62
internal/run/knowledge_collection_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateKnowledgeSelectionsRejectsUnrelatedPlan(t *testing.T) {
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"knowledge_selector": map[string]interface{}{"derived_field": "matched"},
|
||||||
|
"knowledge_collection": map[string]interface{}{"steps": []map[string]interface{}{
|
||||||
|
{"field_key": "segments", "name": "客户分群", "root": true, "required": true, "multiple": true},
|
||||||
|
{"field_key": "strategies", "name": "推荐策略", "from_field": "segments", "relation_type": "matched_strategy", "required": true, "multiple": true},
|
||||||
|
{"field_key": "offers", "name": "推荐内容", "from_field": "strategies", "relation_type": "recommended_offer", "required": true, "multiple": true},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
configRaw, _ := json.Marshal(config)
|
||||||
|
snapshotRaw, _ := json.Marshal(knowledgeSnapshot{
|
||||||
|
Items: []model.KnowledgeItem{
|
||||||
|
{Base: model.Base{ID: 1}, ItemKey: "vip", Name: "高价值客户", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 2}, ItemKey: "renewal", Name: "续费策略", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 3}, ItemKey: "annual", Name: "年度套餐", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 4}, ItemKey: "trial", Name: "试用课程", Status: "active"},
|
||||||
|
},
|
||||||
|
Relations: []model.KnowledgeRelation{
|
||||||
|
{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "matched_strategy"},
|
||||||
|
{FromKnowledgeID: 2, ToKnowledgeID: 3, RelationType: "recommended_offer"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"高价值客户"}})
|
||||||
|
run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)}
|
||||||
|
node := model.SOPNode{Type: "knowledge", Config: datatypes.JSON(configRaw)}
|
||||||
|
valid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"年度套餐"}}
|
||||||
|
if err := validateKnowledgeSelections(node, run, valid); err != nil {
|
||||||
|
t.Fatalf("valid selection rejected: %v", err)
|
||||||
|
}
|
||||||
|
invalid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"试用课程"}}
|
||||||
|
if err := validateKnowledgeSelections(node, run, invalid); err == nil {
|
||||||
|
t.Fatal("unrelated plan should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateKnowledgeSelectionsAllowsAdditionalRootFromConfiguredKnowledgeType(t *testing.T) {
|
||||||
|
configRaw := datatypes.JSON([]byte(`{"knowledge_selector":{"derived_field":"matched"},"knowledge_collection":{"steps":[{"field_key":"symptoms","name":"症状","root":true,"candidate_scope":"all","knowledge_types":["symptom"],"required":true,"multiple":true}]}}`))
|
||||||
|
snapshotRaw, _ := json.Marshal(knowledgeSnapshot{Items: []model.KnowledgeItem{
|
||||||
|
{Base: model.Base{ID: 1}, ItemKey: "diarrhea", Name: "腹泻", Type: "symptom", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 2}, ItemKey: "vomiting", Name: "呕吐", Type: "symptom", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 3}, ItemKey: "disease", Name: "胃肠炎", Type: "disease", Status: "active"},
|
||||||
|
}})
|
||||||
|
derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"腹泻"}})
|
||||||
|
run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)}
|
||||||
|
node := model.SOPNode{Type: "knowledge", Config: configRaw}
|
||||||
|
if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"腹泻", "呕吐"}}); err != nil {
|
||||||
|
t.Fatalf("additional symptom should be accepted: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"胃肠炎"}}); err == nil {
|
||||||
|
t.Fatal("knowledge item of another type should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/run/knowledge_graph_test.go
Normal file
88
internal/run/knowledge_graph_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderKnowledgeContent(t *testing.T) {
|
||||||
|
got := string(renderKnowledgeContent([]byte(`{"template":"您好{{input.name}},标签:{{derived.tags}}"}`), map[string]interface{}{"name": "王女士", "tags": []interface{}{"高意向", "复购"}}))
|
||||||
|
want := `{"template":"您好王女士,标签:高意向、复购"}`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderKnowledgeContentSupportsFormNamespace(t *testing.T) {
|
||||||
|
got := string(renderKnowledgeContent([]byte(`{"template":"结果:{{form.call_result}}"}`), runtimeContext(nil, nil, map[string]interface{}{"call_result": "已接受"})))
|
||||||
|
if got != `{"template":"结果:已接受"}` {
|
||||||
|
t.Fatalf("got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateNamespacesDoNotOverwriteEachOther(t *testing.T) {
|
||||||
|
context := runtimeContext(map[string]interface{}{"status": "input"}, map[string]interface{}{"status": "derived"}, map[string]interface{}{"status": "form"})
|
||||||
|
got := string(renderKnowledgeContent([]byte(`{"template":"{{input.status}}/{{derived.status}}/{{form.status}}"}`), context))
|
||||||
|
if got != `{"template":"input/derived/form"}` {
|
||||||
|
t.Fatalf("got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeRelationCondition(t *testing.T) {
|
||||||
|
condition := json.RawMessage(`{"field":"derived.segment","operator":"equals","value":"vip"}`)
|
||||||
|
matched, err := matchCondition(condition, map[string]interface{}{"segment": "vip"})
|
||||||
|
if err != nil || !matched {
|
||||||
|
t.Fatalf("matched=%v err=%v", matched, err)
|
||||||
|
}
|
||||||
|
matched, err = matchCondition(condition, map[string]interface{}{"segment": "normal"})
|
||||||
|
if err != nil || matched {
|
||||||
|
t.Fatalf("matched=%v err=%v", matched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotRelationsHonorSelector(t *testing.T) {
|
||||||
|
snapshot := knowledgeSnapshot{Relations: []model.KnowledgeRelation{
|
||||||
|
{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "recommended_copy"},
|
||||||
|
{FromKnowledgeID: 1, ToKnowledgeID: 3, RelationType: "internal_note"},
|
||||||
|
}}
|
||||||
|
raw, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parsed, err := parseKnowledgeSnapshot(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selector := knowledgeSelector{RelationTypes: []string{"recommended_copy"}}
|
||||||
|
filtered := make([]model.KnowledgeRelation, 0)
|
||||||
|
for _, relation := range parsed.Relations {
|
||||||
|
if len(selector.RelationTypes) == 0 || containsString(selector.RelationTypes, relation.RelationType) {
|
||||||
|
filtered = append(filtered, relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) != 1 || filtered[0].RelationType != "recommended_copy" {
|
||||||
|
t.Fatalf("filtered relations = %#v", filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeSelectorWithoutCandidateKeysReturnsEmpty(t *testing.T) {
|
||||||
|
outputs, err := loadKnowledgeOutputsForScenario(nil, 1, 1, map[string]interface{}{}, knowledgeSelector{DerivedField: "matched", KnowledgeTypes: []string{"symptom"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(outputs) != 0 {
|
||||||
|
t.Fatalf("outputs = %#v", outputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeCandidateMatchesKeyOrDisplayName(t *testing.T) {
|
||||||
|
item := model.KnowledgeItem{ItemKey: "xlsx_symptom_123", Name: "腹泻"}
|
||||||
|
if !knowledgeCandidateMatches(map[string]bool{"腹泻": true}, item) {
|
||||||
|
t.Fatal("display name should match an external symptom tag")
|
||||||
|
}
|
||||||
|
if !knowledgeCandidateMatches(map[string]bool{"xlsx_symptom_123": true}, item) {
|
||||||
|
t.Fatal("item key should remain supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
93
internal/run/mapping.go
Normal file
93
internal/run/mapping.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pathTokenPattern = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_]*)(?:\[(\d+|\*)\])?$`)
|
||||||
|
|
||||||
|
func mapScenarioInput(fields []model.ScenarioField, input map[string]interface{}) map[string]interface{} {
|
||||||
|
mapped := make(map[string]interface{})
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.SourcePath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value, ok := extractPath(input, field.SourcePath); ok {
|
||||||
|
mapped[field.FieldKey] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPath(root map[string]interface{}, path string) (interface{}, bool) {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
return walkPath(root, parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkPath(current interface{}, parts []string) (interface{}, bool) {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
|
match := pathTokenPattern.FindStringSubmatch(parts[0])
|
||||||
|
if match == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
object, ok := current.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
value, ok := object[match[1]]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if match[2] == "" {
|
||||||
|
return walkPath(value, parts[1:])
|
||||||
|
}
|
||||||
|
items, ok := value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if match[2] == "*" {
|
||||||
|
values := make([]interface{}, 0)
|
||||||
|
for _, item := range items {
|
||||||
|
resolved, found := walkPath(item, parts[1:])
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nested, ok := resolved.([]interface{}); ok {
|
||||||
|
values = append(values, nested...)
|
||||||
|
} else {
|
||||||
|
values = append(values, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, len(values) > 0
|
||||||
|
}
|
||||||
|
index, err := strconv.Atoi(match[2])
|
||||||
|
if err != nil || index < 0 || index >= len(items) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return walkPath(items[index], parts[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeValues(base map[string]interface{}, overrides map[string]interface{}) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{}, len(base)+len(overrides))
|
||||||
|
for key, value := range base {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
for key, value := range overrides {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExternalRef(value string) error {
|
||||||
|
if len(value) > 191 {
|
||||||
|
return fmt.Errorf("external_ref 不能超过191个字符")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
24
internal/run/mapping_test.go
Normal file
24
internal/run/mapping_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMapScenarioInputSupportsArrays(t *testing.T) {
|
||||||
|
fields := []model.ScenarioField{{FieldKey: "customer_name", SourcePath: "customer.name"}, {FieldKey: "product_ids", SourcePath: "order.items[*].product_id"}}
|
||||||
|
input := map[string]interface{}{"customer": map[string]interface{}{"name": "王女士"}, "order": map[string]interface{}{"items": []interface{}{map[string]interface{}{"product_id": "P1"}, map[string]interface{}{"product_id": "P2"}}}}
|
||||||
|
want := map[string]interface{}{"customer_name": "王女士", "product_ids": []interface{}{"P1", "P2"}}
|
||||||
|
if got := mapScenarioInput(fields, input); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("mapped input = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeValuesUsesExplicitValues(t *testing.T) {
|
||||||
|
got := mergeValues(map[string]interface{}{"name": "mapped"}, map[string]interface{}{"name": "explicit"})
|
||||||
|
if got["name"] != "explicit" {
|
||||||
|
t.Fatalf("name = %v", got["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/run/node_view_test.go
Normal file
22
internal/run/node_view_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) {
|
||||||
|
raw, err := json.Marshal(NodeView{NodeKey: "knowledge", Type: "knowledge", Title: "知识", Content: "内容"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var value map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"id", "tenant_id", "sop_version_id", "config", "position_x", "position_y"} {
|
||||||
|
if _, exists := value[key]; exists {
|
||||||
|
t.Fatalf("public node contains internal field %s: %s", key, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
internal/run/outputs.go
Normal file
101
internal/run/outputs.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type outputSchema struct {
|
||||||
|
Fields []outputField `json:"fields"`
|
||||||
|
}
|
||||||
|
type outputField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceField string `json:"source_field"`
|
||||||
|
Default interface{} `json:"default"`
|
||||||
|
}
|
||||||
|
type OutputView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildScenarioOutputs(db *gorm.DB, run model.SOPRun, knowledge []KnowledgeGroup) ([]OutputView, error) {
|
||||||
|
var scenario model.Scenario
|
||||||
|
if err := db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
input := map[string]interface{}{}
|
||||||
|
derived := map[string]interface{}{}
|
||||||
|
answers := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Input, &input)
|
||||||
|
_ = json.Unmarshal(run.Derived, &derived)
|
||||||
|
_ = json.Unmarshal(run.Answers, &answers)
|
||||||
|
return buildOutputViews(scenario.OutputSchema, input, derived, answers, knowledge, run.Status, run.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}, knowledge []KnowledgeGroup, status, result string) ([]OutputView, error) {
|
||||||
|
var schema outputSchema
|
||||||
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]OutputView, 0, len(schema.Fields))
|
||||||
|
for _, field := range schema.Fields {
|
||||||
|
sourceField := field.SourceField
|
||||||
|
var value interface{}
|
||||||
|
var ok bool
|
||||||
|
switch field.Source {
|
||||||
|
case "derived":
|
||||||
|
if sourceField == "" {
|
||||||
|
sourceField = field.Key
|
||||||
|
}
|
||||||
|
value, ok = derived[sourceField]
|
||||||
|
case "knowledge":
|
||||||
|
if sourceField == "" {
|
||||||
|
value, ok = knowledge, true
|
||||||
|
} else {
|
||||||
|
for _, group := range knowledge {
|
||||||
|
if group.Key == sourceField {
|
||||||
|
value, ok = group, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "form":
|
||||||
|
if sourceField == "" {
|
||||||
|
sourceField = field.Key
|
||||||
|
}
|
||||||
|
value, ok = answers[sourceField]
|
||||||
|
case "system":
|
||||||
|
if sourceField == "status" {
|
||||||
|
value, ok = status, true
|
||||||
|
} else if sourceField == "result" {
|
||||||
|
value, ok = result, true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if sourceField == "" {
|
||||||
|
sourceField = field.Key
|
||||||
|
}
|
||||||
|
value, ok = input[sourceField]
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
value = field.Default
|
||||||
|
}
|
||||||
|
name := field.Name
|
||||||
|
if name == "" {
|
||||||
|
name = field.Display
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = field.Key
|
||||||
|
}
|
||||||
|
views = append(views, OutputView{Key: field.Key, Name: name, Type: field.Type, Source: field.Source, Value: value})
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
21
internal/run/outputs_test.go
Normal file
21
internal/run/outputs_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildOutputViewsSupportsKnowledge(t *testing.T) {
|
||||||
|
knowledge := []KnowledgeGroup{{Key: "soft_stool", Name: "软便", Type: "symptom"}}
|
||||||
|
raw := []byte(`{"fields":[{"key":"recommended","name":"推荐知识","type":"array","source":"knowledge"},{"key":"symptom","name":"症状","type":"object","source":"knowledge","source_field":"soft_stool"}]}`)
|
||||||
|
outputs, err := buildOutputViews(raw, nil, nil, nil, knowledge, "preview", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(outputs) != 2 {
|
||||||
|
t.Fatalf("outputs=%#v", outputs)
|
||||||
|
}
|
||||||
|
if groups, ok := outputs[0].Value.([]KnowledgeGroup); !ok || len(groups) != 1 {
|
||||||
|
t.Fatalf("knowledge output=%#v", outputs[0].Value)
|
||||||
|
}
|
||||||
|
if group, ok := outputs[1].Value.(KnowledgeGroup); !ok || group.Key != "soft_stool" {
|
||||||
|
t.Fatalf("selected output=%#v", outputs[1].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
internal/run/preview.go
Normal file
62
internal/run/preview.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) PreviewScenario(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
scenarioID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || scenarioID == 0 || !access.CanViewScenario(h.db, p, scenarioID) {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Input map[string]interface{} `json:"input"`
|
||||||
|
InitialValues map[string]interface{} `json:"initial_values"`
|
||||||
|
Selector knowledgeSelector `json:"knowledge_selector"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "预览参数格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var scenario model.Scenario
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fields []model.ScenarioField
|
||||||
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenarioID, p.TenantID).Order("sort_order,id").Find(&fields).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景输入失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mapped := mergeValues(mapScenarioInput(fields, body.Input), body.InitialValues)
|
||||||
|
if err := validateInitialAnswers(fields, mapped); err != nil {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INPUT", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
derived, matchedRules, err := deriveForScenario(h.db, p.TenantID, scenarioID, mapped)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context := runtimeContext(mapped, derived, nil)
|
||||||
|
knowledge, err := loadKnowledgeOutputsForScenario(h.db, scenarioID, p.TenantID, context, body.Selector)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成知识预览失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outputs, err := buildOutputViews(scenario.OutputSchema, mapped, derived, map[string]interface{}{}, knowledge, "preview", "")
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"input": mapped, "derived": derived, "matched_rules": matchedRules, "knowledge": knowledge, "outputs": outputs})
|
||||||
|
}
|
||||||
458
internal/run/public.go
Normal file
458
internal/run/public.go
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errPublicRunCompleted = errors.New("执行记录已经结束")
|
||||||
|
errPublicNodeChanged = errors.New("当前步骤已经变化")
|
||||||
|
errPublicInputNeeded = errors.New("当前节点需要提交表单")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) PublicStart(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
PublicKey string `json:"public_key" binding:"required"`
|
||||||
|
SOPID uint64 `json:"sop_id"`
|
||||||
|
Input map[string]interface{} `json:"input"`
|
||||||
|
InitialValues map[string]interface{} `json:"initial_values"`
|
||||||
|
ExternalRef string `json:"external_ref"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "公开场景参数不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var scenario model.Scenario
|
||||||
|
if err := h.db.Where("scenario_key = ? AND public_key = ? AND status <> ?", c.Param("scenarioKey"), body.PublicKey, "archived").First(&scenario).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "公开场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", scenario.ID, scenario.TenantID, "published")
|
||||||
|
if body.SOPID != 0 {
|
||||||
|
query = query.Where("id = ?", body.SOPID)
|
||||||
|
}
|
||||||
|
var sop model.SOP
|
||||||
|
if err := query.Order("updated_at DESC").First(&sop).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ExternalRef != "" {
|
||||||
|
var existing model.SOPRun
|
||||||
|
if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; err == nil {
|
||||||
|
token := uuid.NewString() + uuid.NewString()
|
||||||
|
if err := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SESSION_FAILED", "创建 SDK 会话失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondPublicRun(c, existing, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var version model.SOPVersion
|
||||||
|
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, scenario.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := make([]model.ScenarioField, 0)
|
||||||
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenario.ID, scenario.TenantID).Order("sort_order, id").Find(&fields).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景契约失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalized := mergeValues(mapScenarioInput(fields, body.Input), body.InitialValues)
|
||||||
|
if err := validateInitialAnswers(fields, normalized); err != nil {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INPUT", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(normalized)
|
||||||
|
derived, matchedRules, err := deriveForScenario(h.db, scenario.TenantID, scenario.ID, normalized)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
derivedRaw, _ := json.Marshal(derived)
|
||||||
|
knowledgeRaw, err := snapshotKnowledge(h.db, scenario.TenantID, scenario.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
externalRef := body.ExternalRef
|
||||||
|
if externalRef == "" {
|
||||||
|
externalRef = "run-" + uuid.NewString()
|
||||||
|
}
|
||||||
|
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, SOPVersionID: version.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), StartedAt: time.Now()}
|
||||||
|
token := uuid.NewString() + uuid.NewString()
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&run).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: run.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(gin.H{"source": "public_sdk", "mapped_field_keys": sortedKeys(normalized), "matched_rule_keys": matchedRules})
|
||||||
|
return tx.Create(&model.SOPRunEvent{TenantID: scenario.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if body.ExternalRef != "" {
|
||||||
|
var existing model.SOPRun
|
||||||
|
if findErr := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; findErr == nil {
|
||||||
|
token = uuid.NewString() + uuid.NewString()
|
||||||
|
if sessionErr := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; sessionErr == nil {
|
||||||
|
h.respondPublicRun(c, existing, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动公开场景失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondPublicRun(c, run, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PublicCurrent(c *gin.Context) {
|
||||||
|
session, run, ok := h.publicSession(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = session
|
||||||
|
h.respondPublicRun(c, run, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PublicSubmit(c *gin.Context) {
|
||||||
|
session, _, ok := h.publicSession(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
NodeKey string `json:"node_key"`
|
||||||
|
Answers map[string]interface{} `json:"answers"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "提交内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var run model.SOPRun
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := lockPublicRun(tx, session, &run); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if run.Status != "running" {
|
||||||
|
return errPublicRunCompleted
|
||||||
|
}
|
||||||
|
if body.NodeKey != "" && body.NodeKey != run.CurrentNodeKey {
|
||||||
|
return errPublicNodeChanged
|
||||||
|
}
|
||||||
|
var node model.SOPNode
|
||||||
|
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fields := make([]model.ScenarioField, 0)
|
||||||
|
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateNodeAnswers(node, fields, body.Answers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
answers := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Answers, &answers)
|
||||||
|
for key, value := range body.Answers {
|
||||||
|
answers[key] = value
|
||||||
|
}
|
||||||
|
answerRaw, _ := json.Marshal(answers)
|
||||||
|
run.Answers = datatypes.JSON(answerRaw)
|
||||||
|
payload, _ := json.Marshal(body.Answers)
|
||||||
|
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return advancePublicRun(tx, &run)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.respondPublicMutationError(c, err, "提交节点失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondPublicRun(c, run, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PublicNext(c *gin.Context) {
|
||||||
|
session, _, ok := h.publicSession(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var run model.SOPRun
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := lockPublicRun(tx, session, &run); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if run.Status != "running" {
|
||||||
|
return errPublicRunCompleted
|
||||||
|
}
|
||||||
|
var node model.SOPNode
|
||||||
|
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
|
||||||
|
return errPublicInputNeeded
|
||||||
|
}
|
||||||
|
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return advancePublicRun(tx, &run)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.respondPublicMutationError(c, err, "推进节点失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondPublicRun(c, run, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PublicFinish(c *gin.Context) {
|
||||||
|
session, _, ok := h.publicSession(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
FinalResult json.RawMessage `json:"final_result"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "最终结果格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var run model.SOPRun
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := lockPublicRun(tx, session, &run); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if run.Status != "running" && run.Status != "completed" {
|
||||||
|
return errPublicRunCompleted
|
||||||
|
}
|
||||||
|
if body.Result == "" {
|
||||||
|
body.Result = run.Result
|
||||||
|
if body.Result == "" {
|
||||||
|
body.Result = "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalResult, err := parseFinalResult(body.FinalResult)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var scenario model.Scenario
|
||||||
|
if err := tx.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
schema, err := resultcontract.ParseAndValidate(scenario.ResultSchema)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := resultcontract.ValidateResult(schema, finalResult); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
finalRaw, _ := json.Marshal(finalResult)
|
||||||
|
now := time.Now()
|
||||||
|
completedAt := run.CompletedAt
|
||||||
|
if completedAt == nil {
|
||||||
|
completedAt = &now
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(gin.H{"result": body.Result, "final_result": finalResult})
|
||||||
|
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": body.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
run.Status, run.Result, run.FinalResult, run.CompletedAt = "completed", body.Result, datatypes.JSON(finalRaw), completedAt
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.respondPublicMutationError(c, err, "结束执行失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondPublicRun(c, run, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PublicReset(c *gin.Context) {
|
||||||
|
session, _, ok := h.publicSession(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var run model.SOPRun
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := lockPublicRun(tx, session, &run); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var version model.SOPVersion
|
||||||
|
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPVersionID, run.TenantID).First(&version).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = version.StartNodeKey, "running", "", nil, nil, run.Input
|
||||||
|
if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`))}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "reset", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.respondPublicMutationError(c, err, "重置执行失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondPublicRun(c, run, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
|
||||||
|
var edges []model.SOPEdge
|
||||||
|
if err := tx.Where("sop_version_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).Order("priority,id").Find(&edges).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
answers := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Answers, &answers)
|
||||||
|
derived := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Derived, &derived)
|
||||||
|
input := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Input, &input)
|
||||||
|
context := runtimeContext(input, derived, answers)
|
||||||
|
sortEdges(edges)
|
||||||
|
nextKey := ""
|
||||||
|
for _, edge := range edges {
|
||||||
|
matched, err := matchCondition(json.RawMessage(edge.Condition), context)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
nextKey = edge.TargetNodeKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nextKey == "" {
|
||||||
|
return errors.New("没有满足条件的下一节点")
|
||||||
|
}
|
||||||
|
var next model.SOPNode
|
||||||
|
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, nextKey, run.TenantID).First(&next).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{"current_node_key": nextKey}
|
||||||
|
if next.Type == "finish" || next.Type == "escalate" {
|
||||||
|
now := time.Now()
|
||||||
|
updates["status"] = "completed"
|
||||||
|
updates["completed_at"] = &now
|
||||||
|
updates["result"] = next.Type
|
||||||
|
run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type
|
||||||
|
}
|
||||||
|
if err := tx.Model(run).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
run.CurrentNodeKey = nextKey
|
||||||
|
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if run.Status == "completed" {
|
||||||
|
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockPublicRun(tx *gorm.DB, session model.PublicRunSession, run *model.SOPRun) error {
|
||||||
|
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", session.RunID, session.TenantID).First(run).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) respondPublicMutationError(c *gin.Context, err error, fallback string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errPublicRunCompleted):
|
||||||
|
response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error())
|
||||||
|
case errors.Is(err, errPublicNodeChanged):
|
||||||
|
response.Error(c, http.StatusConflict, "NODE_CHANGED", err.Error())
|
||||||
|
case errors.Is(err, errPublicInputNeeded):
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INPUT_REQUIRED", err.Error())
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录或流程节点不存在")
|
||||||
|
default:
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", fallback+": "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token string) {
|
||||||
|
var node model.SOPNode
|
||||||
|
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
answers := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Answers, &answers)
|
||||||
|
derived := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Derived, &derived)
|
||||||
|
input := map[string]interface{}{}
|
||||||
|
_ = json.Unmarshal(run.Input, &input)
|
||||||
|
context := runtimeContext(input, derived, answers)
|
||||||
|
context["__knowledge_snapshot"] = json.RawMessage(run.KnowledgeSnapshot)
|
||||||
|
view, err := h.nodeView(h.db, node, run.TenantID, context)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成节点输出失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.Config = nil
|
||||||
|
outputs, err := buildScenarioOutputs(h.db, run, view.Outputs)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw, marshalErr := json.Marshal(outputs); marshalErr == nil {
|
||||||
|
run.Outputs = datatypes.JSON(raw)
|
||||||
|
_ = h.db.Model(&model.SOPRun{}).Where("id = ?", run.ID).Update("outputs", run.Outputs).Error
|
||||||
|
}
|
||||||
|
var scenario model.Scenario
|
||||||
|
if err := h.db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "读取场景结果格式失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := gin.H{"run_id": run.ID, "external_ref": run.ExternalRef, "status": run.Status, "final_result": json.RawMessage(run.FinalResult), "result_schema": json.RawMessage(scenario.ResultSchema), "node": view, "outputs": outputs}
|
||||||
|
if token != "" {
|
||||||
|
data["session_token"] = token
|
||||||
|
}
|
||||||
|
response.OK(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFinalResult(raw json.RawMessage) (map[string]interface{}, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &result); err != nil || result == nil {
|
||||||
|
return nil, errors.New("final_result 必须是对象或 null")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) publicSession(c *gin.Context) (model.PublicRunSession, model.SOPRun, bool) {
|
||||||
|
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
var session model.PublicRunSession
|
||||||
|
if raw == "" || h.db.Where("token_hash = ? AND expires_at > ?", publicTokenHash(raw), time.Now()).First(&session).Error != nil {
|
||||||
|
response.Error(c, http.StatusUnauthorized, "INVALID_SESSION", "SDK 会话无效或已过期")
|
||||||
|
return session, model.SOPRun{}, false
|
||||||
|
}
|
||||||
|
var run model.SOPRun
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", c.Param("id"), session.TenantID).First(&run).Error; err != nil || run.ID != session.RunID {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||||
|
return session, run, false
|
||||||
|
}
|
||||||
|
return session, run, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicTokenHash(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
89
internal/run/rules.go
Normal file
89
internal/run/rules.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func deriveForScenario(db *gorm.DB, tenantID, scenarioID uint64, input map[string]interface{}) (map[string]interface{}, []string, error) {
|
||||||
|
rules := make([]model.ScenarioRule, 0)
|
||||||
|
if err := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active").Order("priority, id").Find(&rules).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return applyScenarioRules(rules, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ruleAction struct {
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Field string `json:"field"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
ValueFrom string `json:"value_from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyScenarioRules(rules []model.ScenarioRule, input map[string]interface{}) (map[string]interface{}, []string, error) {
|
||||||
|
derived := map[string]interface{}{}
|
||||||
|
matched := make([]string, 0)
|
||||||
|
context := runtimeContext(input, derived, nil)
|
||||||
|
for _, rule := range rules {
|
||||||
|
ok, err := matchCondition(json.RawMessage(rule.Condition), context)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("规则 %s 条件不正确: %w", rule.RuleKey, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var actions []ruleAction
|
||||||
|
if err := json.Unmarshal(rule.Actions, &actions); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("规则 %s 动作不正确", rule.RuleKey)
|
||||||
|
}
|
||||||
|
for _, action := range actions {
|
||||||
|
resolvedValues := []interface{}{action.Value}
|
||||||
|
valueFromList := false
|
||||||
|
if action.ValueFrom != "" {
|
||||||
|
resolved, exists := lookupContextValue(context, action.ValueFrom)
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if list, ok := resolved.([]interface{}); ok {
|
||||||
|
resolvedValues = list
|
||||||
|
valueFromList = true
|
||||||
|
} else {
|
||||||
|
resolvedValues = []interface{}{resolved}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch action.Operation {
|
||||||
|
case "set":
|
||||||
|
if valueFromList {
|
||||||
|
derived[action.Field] = resolvedValues
|
||||||
|
} else if len(resolvedValues) == 1 {
|
||||||
|
derived[action.Field] = resolvedValues[0]
|
||||||
|
} else {
|
||||||
|
derived[action.Field] = resolvedValues
|
||||||
|
}
|
||||||
|
case "append":
|
||||||
|
targetValues, _ := derived[action.Field].([]interface{})
|
||||||
|
for _, value := range resolvedValues {
|
||||||
|
duplicate := false
|
||||||
|
for _, existing := range targetValues {
|
||||||
|
if fmt.Sprint(existing) == fmt.Sprint(value) {
|
||||||
|
duplicate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !duplicate {
|
||||||
|
targetValues = append(targetValues, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
derived[action.Field] = targetValues
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("规则 %s 使用了不支持的动作", rule.RuleKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched = append(matched, rule.RuleKey)
|
||||||
|
context = runtimeContext(input, derived, nil)
|
||||||
|
}
|
||||||
|
return derived, matched, nil
|
||||||
|
}
|
||||||
39
internal/run/rules_test.go
Normal file
39
internal/run/rules_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyScenarioRules(t *testing.T) {
|
||||||
|
rules := []model.ScenarioRule{{RuleKey: "r1", Condition: datatypes.JSON([]byte(`{"field":"tags","operator":"contains","value":"soft"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"append","field":"symptoms","value":"soft_stool"}]`))}}
|
||||||
|
derived, matched, err := applyScenarioRules(rules, map[string]interface{}{"tags": []interface{}{"soft"}})
|
||||||
|
if err != nil || len(matched) != 1 || len(derived["symptoms"].([]interface{})) != 1 {
|
||||||
|
t.Fatalf("derived=%v matched=%v err=%v", derived, matched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyScenarioRulesSupportsValueFrom(t *testing.T) {
|
||||||
|
rules := []model.ScenarioRule{{RuleKey: "copy_tags", Condition: datatypes.JSON([]byte(`{"field":"input_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"append","field":"matched_symptoms","value_from":"input_tags"}]`))}}
|
||||||
|
derived, _, err := applyScenarioRules(rules, map[string]interface{}{"input_tags": []interface{}{"soft_stool", "poor_appetite"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values, ok := derived["matched_symptoms"].([]interface{})
|
||||||
|
if !ok || len(values) != 2 || values[0] != "soft_stool" || values[1] != "poor_appetite" {
|
||||||
|
t.Fatalf("derived = %#v", derived)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyScenarioRulesSupportsNamespacedValueFrom(t *testing.T) {
|
||||||
|
rules := []model.ScenarioRule{{RuleKey: "copy_tags", Condition: datatypes.JSON([]byte(`{"field":"input.input_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_tags"}]`))}}
|
||||||
|
derived, _, err := applyScenarioRules(rules, map[string]interface{}{"input_tags": []interface{}{"soft_stool"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values, ok := derived["matched_symptoms"].([]interface{})
|
||||||
|
if !ok || len(values) != 1 || values[0] != "soft_stool" {
|
||||||
|
t.Fatalf("derived = %#v", derived)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/run/snapshot.go
Normal file
29
internal/run/snapshot.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type knowledgeSnapshot struct {
|
||||||
|
Items []model.KnowledgeItem `json:"items"`
|
||||||
|
Relations []model.KnowledgeRelation `json:"relations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotKnowledge(db *gorm.DB, tenantID, scenarioID uint64) ([]byte, error) {
|
||||||
|
var snapshot knowledgeSnapshot
|
||||||
|
if err := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active").Order("sort_order,id").Find(&snapshot.Items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order,id").Find(&snapshot.Relations).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKnowledgeSnapshot(raw []byte) (knowledgeSnapshot, error) {
|
||||||
|
var snapshot knowledgeSnapshot
|
||||||
|
err := json.Unmarshal(raw, &snapshot)
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
160
internal/run/start_presentation.go
Normal file
160
internal/run/start_presentation.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NodePresentation struct {
|
||||||
|
Summary []PresentationField `json:"summary"`
|
||||||
|
Items []PresentationItem `json:"items"`
|
||||||
|
Opening *PresentationCopy `json:"opening,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresentationField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresentationItem struct {
|
||||||
|
Fields []PresentationField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresentationCopy struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startNodeConfig struct {
|
||||||
|
Presentation *startPresentationConfig `json:"presentation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startPresentationConfig struct {
|
||||||
|
SummaryFieldKeys []string `json:"summary_field_keys"`
|
||||||
|
ItemFieldKeys []string `json:"item_field_keys"`
|
||||||
|
ImageFieldKeys []string `json:"image_field_keys"`
|
||||||
|
OpeningTitle string `json:"opening_title"`
|
||||||
|
OpeningTemplate string `json:"opening_template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}) (*NodePresentation, error) {
|
||||||
|
var config startNodeConfig
|
||||||
|
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if config.Presentation == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
keys := append([]string{}, config.Presentation.SummaryFieldKeys...)
|
||||||
|
keys = append(keys, config.Presentation.ItemFieldKeys...)
|
||||||
|
fields := make([]model.ScenarioField, 0)
|
||||||
|
if len(keys) > 0 {
|
||||||
|
if err := db.Table("scenario_fields sf").Select("sf.*").
|
||||||
|
Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").
|
||||||
|
Joins("JOIN sop_versions sv ON sv.sop_id = s.id").
|
||||||
|
Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).
|
||||||
|
Find(&fields).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byKey := make(map[string]model.ScenarioField, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
byKey[field.FieldKey] = field
|
||||||
|
}
|
||||||
|
return buildStartPresentation(*config.Presentation, byKey, context), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStartPresentation(config startPresentationConfig, fields map[string]model.ScenarioField, context map[string]interface{}) *NodePresentation {
|
||||||
|
view := &NodePresentation{Summary: []PresentationField{}, Items: []PresentationItem{}}
|
||||||
|
imageKeys := make(map[string]bool, len(config.ImageFieldKeys))
|
||||||
|
for _, key := range config.ImageFieldKeys {
|
||||||
|
imageKeys[key] = true
|
||||||
|
}
|
||||||
|
for _, key := range config.SummaryFieldKeys {
|
||||||
|
value, ok := lookupContextValue(context, "input."+key)
|
||||||
|
if !ok || presentationValueEmpty(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view.Summary = append(view.Summary, presentationField(key, value, fields, imageKeys))
|
||||||
|
}
|
||||||
|
itemValues := make(map[string][]interface{}, len(config.ItemFieldKeys))
|
||||||
|
itemCount := 0
|
||||||
|
for _, key := range config.ItemFieldKeys {
|
||||||
|
value, _ := lookupContextValue(context, "input."+key)
|
||||||
|
values := presentationValues(value)
|
||||||
|
itemValues[key] = values
|
||||||
|
if len(values) > itemCount {
|
||||||
|
itemCount = len(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := 0; index < itemCount; index++ {
|
||||||
|
item := PresentationItem{Fields: []PresentationField{}}
|
||||||
|
for _, key := range config.ItemFieldKeys {
|
||||||
|
values := itemValues[key]
|
||||||
|
if index >= len(values) || presentationValueEmpty(values[index]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.Fields = append(item.Fields, presentationField(key, values[index], fields, imageKeys))
|
||||||
|
}
|
||||||
|
if len(item.Fields) > 0 {
|
||||||
|
view.Items = append(view.Items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.OpeningTemplate != "" {
|
||||||
|
title := config.OpeningTitle
|
||||||
|
if title == "" {
|
||||||
|
title = "开场话术"
|
||||||
|
}
|
||||||
|
view.Opening = &PresentationCopy{Title: title, Content: fmt.Sprint(renderKnowledgeValue(config.OpeningTemplate, context))}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationField(key string, value interface{}, fields map[string]model.ScenarioField, imageKeys map[string]bool) PresentationField {
|
||||||
|
label := key
|
||||||
|
if field, ok := fields[key]; ok && field.FieldName != "" {
|
||||||
|
label = field.FieldName
|
||||||
|
}
|
||||||
|
kind := "text"
|
||||||
|
if imageKeys[key] {
|
||||||
|
kind = "image"
|
||||||
|
}
|
||||||
|
return PresentationField{Key: key, Label: label, Kind: kind, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationValues(value interface{}) []interface{} {
|
||||||
|
switch values := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
return values
|
||||||
|
case []string:
|
||||||
|
result := make([]interface{}, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return []interface{}{value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationValueEmpty(value interface{}) bool {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return true
|
||||||
|
case string:
|
||||||
|
return typed == ""
|
||||||
|
case []interface{}:
|
||||||
|
return len(typed) == 0
|
||||||
|
case []string:
|
||||||
|
return len(typed) == 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/run/start_presentation_test.go
Normal file
46
internal/run/start_presentation_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildStartPresentation(t *testing.T) {
|
||||||
|
config := startPresentationConfig{
|
||||||
|
SummaryFieldKeys: []string{"order_id", "customer_name", "pet_name"},
|
||||||
|
ItemFieldKeys: []string{"product_images", "product_names", "product_ids"},
|
||||||
|
ImageFieldKeys: []string{"product_images"},
|
||||||
|
OpeningTitle: "开场话术",
|
||||||
|
OpeningTemplate: "您好,{{input.customer_name}},看到您购买了{{input.product_names}}。",
|
||||||
|
}
|
||||||
|
fields := map[string]model.ScenarioField{
|
||||||
|
"order_id": {FieldKey: "order_id", FieldName: "订单号"},
|
||||||
|
"customer_name": {FieldKey: "customer_name", FieldName: "客户称呼"},
|
||||||
|
"pet_name": {FieldKey: "pet_name", FieldName: "宠物名称"},
|
||||||
|
"product_images": {FieldKey: "product_images", FieldName: "订单商品图片"},
|
||||||
|
"product_names": {FieldKey: "product_names", FieldName: "订单商品名称"},
|
||||||
|
"product_ids": {FieldKey: "product_ids", FieldName: "订单商品 ID"},
|
||||||
|
}
|
||||||
|
context := runtimeContext(map[string]interface{}{
|
||||||
|
"order_id": "ORDER-001",
|
||||||
|
"customer_name": "王女士",
|
||||||
|
"product_images": []interface{}{"https://img.example.com/a.jpg", "https://img.example.com/b.jpg"},
|
||||||
|
"product_names": []interface{}{"商品 A", "商品 B"},
|
||||||
|
"product_ids": []interface{}{"SKU-A", "SKU-B"},
|
||||||
|
}, nil, nil)
|
||||||
|
|
||||||
|
got := buildStartPresentation(config, fields, context)
|
||||||
|
if len(got.Summary) != 2 {
|
||||||
|
t.Fatalf("summary length = %d, want 2", len(got.Summary))
|
||||||
|
}
|
||||||
|
if len(got.Items) != 2 || len(got.Items[0].Fields) != 3 {
|
||||||
|
t.Fatalf("items = %#v, want two complete product rows", got.Items)
|
||||||
|
}
|
||||||
|
if got.Items[0].Fields[0].Kind != "image" {
|
||||||
|
t.Fatalf("image kind = %q, want image", got.Items[0].Fields[0].Kind)
|
||||||
|
}
|
||||||
|
if got.Opening == nil || got.Opening.Content != "您好,王女士,看到您购买了商品 A、商品 B。" {
|
||||||
|
t.Fatalf("opening = %#v", got.Opening)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,37 @@ import (
|
|||||||
type answerNodeConfig struct {
|
type answerNodeConfig struct {
|
||||||
FieldKey string `json:"field_key"`
|
FieldKey string `json:"field_key"`
|
||||||
FieldKeys []string `json:"field_keys"`
|
FieldKeys []string `json:"field_keys"`
|
||||||
|
RequiredFieldKeys []string `json:"required_field_keys"`
|
||||||
Required bool `json:"required"`
|
Required bool `json:"required"`
|
||||||
Options []string `json:"options"`
|
Options []string `json:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateInitialAnswers accepts a partial, externally supplied set of values
|
||||||
|
// when an execution starts. Required fields are still enforced by their
|
||||||
|
// collection nodes, so integrations can supply only the data they possess.
|
||||||
|
func validateInitialAnswers(fields []model.ScenarioField, answers map[string]interface{}) error {
|
||||||
|
fieldMap := make(map[string]model.ScenarioField, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
fieldMap[field.FieldKey] = field
|
||||||
|
if field.Required && field.SourcePath != "" && isEmptyValue(answers[field.FieldKey]) {
|
||||||
|
return fmt.Errorf("缺少必填输入%s", field.FieldName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, value := range answers {
|
||||||
|
field, exists := fieldMap[key]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("传入字段 %s 不存在", key)
|
||||||
|
}
|
||||||
|
if isEmptyValue(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateFieldValue(field, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
|
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
|
||||||
var config answerNodeConfig
|
var config answerNodeConfig
|
||||||
if len(node.Config) > 0 {
|
if len(node.Config) > 0 {
|
||||||
@@ -39,9 +66,12 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
|
|||||||
return fmt.Errorf("当前表单没有配置采集字段")
|
return fmt.Errorf("当前表单没有配置采集字段")
|
||||||
}
|
}
|
||||||
for _, key := range config.FieldKeys {
|
for _, key := range config.FieldKeys {
|
||||||
expected[key] = false
|
expected[key] = containsString(config.RequiredFieldKeys, key)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
if node.Type == "knowledge" {
|
||||||
|
return validateKnowledgeNodeAnswers(node, fieldMap, answers)
|
||||||
|
}
|
||||||
if len(answers) > 0 {
|
if len(answers) > 0 {
|
||||||
return fmt.Errorf("当前节点不接受字段回答")
|
return fmt.Errorf("当前节点不接受字段回答")
|
||||||
}
|
}
|
||||||
@@ -76,6 +106,87 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateKnowledgeNodeAnswers(node model.SOPNode, fields map[string]model.ScenarioField, answers map[string]interface{}) error {
|
||||||
|
var config knowledgeNodeConfig
|
||||||
|
if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil {
|
||||||
|
if len(answers) > 0 {
|
||||||
|
return fmt.Errorf("当前节点不接受字段回答")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
expected := map[string]bool{}
|
||||||
|
for _, key := range config.KnowledgeCollection.ContextFieldKeys {
|
||||||
|
expected[key] = fields[key].Required
|
||||||
|
}
|
||||||
|
for _, step := range config.KnowledgeCollection.Steps {
|
||||||
|
expected[step.FieldKey] = step.Required
|
||||||
|
}
|
||||||
|
for key := range answers {
|
||||||
|
if _, ok := expected[key]; !ok {
|
||||||
|
return fmt.Errorf("字段 %s 不属于当前节点", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, required := range expected {
|
||||||
|
value := answers[key]
|
||||||
|
if isEmptyValue(value) {
|
||||||
|
if required {
|
||||||
|
return fmt.Errorf("请填写%s", knowledgeAnswerName(config, fields, key))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if field, ok := fields[key]; ok && !isKnowledgeStep(config, key) {
|
||||||
|
if err := validateFieldValue(field, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isKnowledgeStep(config, key) {
|
||||||
|
if _, ok := stringValues(value); !ok {
|
||||||
|
return fmt.Errorf("%s必须选择有效选项", knowledgeAnswerName(config, fields, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeAnswerName(config knowledgeNodeConfig, fields map[string]model.ScenarioField, key string) string {
|
||||||
|
if field, ok := fields[key]; ok {
|
||||||
|
return field.FieldName
|
||||||
|
}
|
||||||
|
for _, step := range config.KnowledgeCollection.Steps {
|
||||||
|
if step.FieldKey == key {
|
||||||
|
return step.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
func isKnowledgeStep(config knowledgeNodeConfig, key string) bool {
|
||||||
|
for _, step := range config.KnowledgeCollection.Steps {
|
||||||
|
if step.FieldKey == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func stringValues(value interface{}) ([]string, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return []string{typed}, typed != ""
|
||||||
|
case []interface{}:
|
||||||
|
result := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
text, ok := item.(string)
|
||||||
|
if !ok || text == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
case []string:
|
||||||
|
return typed, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func stringAllowed(options []string, selected string) bool {
|
func stringAllowed(options []string, selected string) bool {
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
if option == selected {
|
if option == selected {
|
||||||
@@ -140,6 +251,15 @@ func validateFieldValue(field model.ScenarioField, value interface{}) error {
|
|||||||
return fmt.Errorf("%s包含不正确的选项", field.FieldName)
|
return fmt.Errorf("%s包含不正确的选项", field.FieldName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "array":
|
||||||
|
switch values := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
_ = values
|
||||||
|
case []string:
|
||||||
|
_ = values
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s必须是数组", field.FieldName)
|
||||||
|
}
|
||||||
case "date":
|
case "date":
|
||||||
text, ok := value.(string)
|
text, ok := value.(string)
|
||||||
if !ok || !validDate(text) {
|
if !ok || !validDate(text) {
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateNodeAnswersSupportsNodeRequiredFormFields(t *testing.T) {
|
||||||
|
fields := []model.ScenarioField{{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text"}}
|
||||||
|
node := model.SOPNode{Type: "form", Config: datatypes.JSON([]byte(`{"field_keys":["pet_name"],"required_field_keys":["pet_name"]}`))}
|
||||||
|
if err := validateNodeAnswers(node, fields, map[string]interface{}{}); err == nil || !strings.Contains(err.Error(), "请填写宠物名称") {
|
||||||
|
t.Fatalf("validateNodeAnswers() error = %v, want node-required error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
|
func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
|
||||||
fields := []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "text"}}
|
fields := []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "text"}}
|
||||||
node := model.SOPNode{Type: "choice", Config: datatypes.JSON([]byte(`{"field_key":"intent","required":true,"options":["继续了解","暂不考虑"]}`))}
|
node := model.SOPNode{Type: "choice", Config: datatypes.JSON([]byte(`{"field_key":"intent","required":true,"options":["继续了解","暂不考虑"]}`))}
|
||||||
@@ -58,3 +66,26 @@ func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
|
|||||||
t.Fatalf("validateNodeAnswers() error = %v, want invalid choice option", err)
|
t.Fatalf("validateNodeAnswers() error = %v, want invalid choice option", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateInitialAnswers(t *testing.T) {
|
||||||
|
fields := []model.ScenarioField{
|
||||||
|
{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text", Required: true},
|
||||||
|
{FieldKey: "pet_weight", FieldName: "体重", FieldType: "number"},
|
||||||
|
}
|
||||||
|
if err := validateInitialAnswers(fields, map[string]interface{}{"pet_name": "团子"}); err != nil {
|
||||||
|
t.Fatalf("validateInitialAnswers() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := validateInitialAnswers(fields, map[string]interface{}{"unknown": "value"}); err == nil || !strings.Contains(err.Error(), "不存在") {
|
||||||
|
t.Fatalf("validateInitialAnswers() error = %v, want unknown-field error", err)
|
||||||
|
}
|
||||||
|
if err := validateInitialAnswers(fields, map[string]interface{}{"pet_weight": "heavy"}); err == nil || !strings.Contains(err.Error(), "必须是数字") {
|
||||||
|
t.Fatalf("validateInitialAnswers() error = %v, want value-type error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInitialAnswersRequiresMappedInput(t *testing.T) {
|
||||||
|
fields := []model.ScenarioField{{FieldKey: "customer_id", FieldName: "客户 ID", FieldType: "text", SourcePath: "customer.id", Required: true}}
|
||||||
|
if err := validateInitialAnswers(fields, map[string]interface{}{}); err == nil {
|
||||||
|
t.Fatal("expected missing mapped input to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
280
internal/scenario/contract.go
Normal file
280
internal/scenario/contract.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package scenario
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contractInput struct {
|
||||||
|
OutputSchema map[string]interface{} `json:"output_schema"`
|
||||||
|
ResultSchema map[string]interface{} `json:"result_schema"`
|
||||||
|
Rules []ruleInput `json:"rules"`
|
||||||
|
AllowedOrigins []string `json:"allowed_origins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ruleInput struct {
|
||||||
|
RuleKey string `json:"rule_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Condition map[string]interface{} `json:"condition"`
|
||||||
|
Actions []map[string]interface{} `json:"actions"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetContract(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := contractScenarioID(c)
|
||||||
|
if !ok || !access.CanViewScenario(h.db, p, id) {
|
||||||
|
if ok {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var scenario model.Scenario
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rules := make([]model.ScenarioRule, 0)
|
||||||
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("priority,id").Find(&rules).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景规则失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"input_schema": scenario.InputSchema, "output_schema": scenario.OutputSchema, "result_schema": scenario.ResultSchema, "scenario_key": scenario.ScenarioKey, "public_key": scenario.PublicKey, "allowed_origins": scenario.AllowedOrigins, "rules": rules})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ReplaceContract(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := contractScenarioID(c)
|
||||||
|
if !ok || !access.CanEditScenario(h.db, p, id) {
|
||||||
|
if ok {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input contractInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "契约 JSON 格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.OutputSchema == nil {
|
||||||
|
input.OutputSchema = map[string]interface{}{"fields": []interface{}{}}
|
||||||
|
}
|
||||||
|
if input.ResultSchema == nil {
|
||||||
|
input.ResultSchema = map[string]interface{}{"fields": []interface{}{}}
|
||||||
|
}
|
||||||
|
if err := validateContractInput(input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, rule := range input.Rules {
|
||||||
|
if !fieldKeyPattern.MatchString(rule.RuleKey) || rule.Name == "" || seen[rule.RuleKey] {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "规则标识必须唯一且格式正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[rule.RuleKey] = true
|
||||||
|
if rule.Status != "" && rule.Status != "active" && rule.Status != "disabled" {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "规则状态不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputRaw, _ := json.Marshal(input.OutputSchema)
|
||||||
|
resultRaw, _ := json.Marshal(input.ResultSchema)
|
||||||
|
originsRaw, _ := json.Marshal(input.AllowedOrigins)
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var oldRules []model.ScenarioRule
|
||||||
|
if err := tx.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Find(&oldRules).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(map[string]interface{}{"output_schema": datatypes.JSON(outputRaw), "result_schema": datatypes.JSON(resultRaw), "allowed_origins": datatypes.JSON(originsRaw)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioRule{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, rule := range input.Rules {
|
||||||
|
condition, _ := json.Marshal(rule.Condition)
|
||||||
|
actions, _ := json.Marshal(rule.Actions)
|
||||||
|
status := rule.Status
|
||||||
|
if status == "" {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
row := model.ScenarioRule{TenantID: p.TenantID, ScenarioID: id, RuleKey: rule.RuleKey, Name: rule.Name, Condition: datatypes.JSON(condition), Actions: datatypes.JSON(actions), Priority: rule.Priority, Status: status}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := audit.RecordTx(tx, p, "create", "scenario_rule", row.ID, gin.H{"scenario_id": id, "rule_key": row.RuleKey}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rule := range oldRules {
|
||||||
|
if err := audit.RecordTx(tx, p, "archive", "scenario_rule", rule.ID, gin.H{"scenario_id": id, "rule_key": rule.RuleKey, "name": rule.Name, "priority": rule.Priority, "status": rule.Status}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return audit.RecordTx(tx, p, "update", "scenario", id, gin.H{"contract": true})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存场景契约失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.GetContract(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateContractInput(input contractInput) error {
|
||||||
|
fields, ok := input.OutputSchema["fields"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("output_schema.fields 必须是数组")
|
||||||
|
}
|
||||||
|
outputKeys := map[string]bool{}
|
||||||
|
allowedSources := map[string]bool{"input": true, "derived": true, "knowledge": true, "form": true, "system": true}
|
||||||
|
for index, raw := range fields {
|
||||||
|
field, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("第 %d 个输出字段必须是对象", index+1)
|
||||||
|
}
|
||||||
|
key, _ := field["key"].(string)
|
||||||
|
if !fieldKeyPattern.MatchString(key) || outputKeys[key] {
|
||||||
|
return fmt.Errorf("输出字段标识必须唯一且格式正确")
|
||||||
|
}
|
||||||
|
outputKeys[key] = true
|
||||||
|
source, _ := field["source"].(string)
|
||||||
|
if source == "" {
|
||||||
|
source = "input"
|
||||||
|
}
|
||||||
|
if !allowedSources[source] {
|
||||||
|
return fmt.Errorf("输出字段 %s 使用了不支持的来源 %s", key, source)
|
||||||
|
}
|
||||||
|
if sourceField, exists := field["source_field"]; exists {
|
||||||
|
value, ok := sourceField.(string)
|
||||||
|
if !ok || !fieldKeyPattern.MatchString(value) {
|
||||||
|
return fmt.Errorf("输出字段 %s 的 source_field 格式不正确", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := resultcontract.ValidateSchema(input.ResultSchema); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, origin := range input.AllowedOrigins {
|
||||||
|
if origin == "" || (origin != "*" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://")) {
|
||||||
|
return fmt.Errorf("允许域名必须是完整的 http/https Origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rule := range input.Rules {
|
||||||
|
if len(rule.Condition) == 0 {
|
||||||
|
return fmt.Errorf("规则 %s 的条件不能为空", rule.RuleKey)
|
||||||
|
}
|
||||||
|
if err := validateContractCondition(rule.Condition, 0); err != nil {
|
||||||
|
return fmt.Errorf("规则 %s 条件不正确: %w", rule.RuleKey, err)
|
||||||
|
}
|
||||||
|
if len(rule.Actions) == 0 {
|
||||||
|
return fmt.Errorf("规则 %s 至少需要一个动作", rule.RuleKey)
|
||||||
|
}
|
||||||
|
for _, action := range rule.Actions {
|
||||||
|
operation, _ := action["operation"].(string)
|
||||||
|
field, _ := action["field"].(string)
|
||||||
|
if operation != "set" && operation != "append" {
|
||||||
|
return fmt.Errorf("规则 %s 使用了不支持的动作 %s", rule.RuleKey, operation)
|
||||||
|
}
|
||||||
|
if !fieldKeyPattern.MatchString(field) {
|
||||||
|
return fmt.Errorf("规则 %s 的动作目标字段格式不正确", rule.RuleKey)
|
||||||
|
}
|
||||||
|
valuePresent := false
|
||||||
|
if _, exists := action["value"]; exists {
|
||||||
|
valuePresent = true
|
||||||
|
}
|
||||||
|
if source, exists := action["value_from"]; exists {
|
||||||
|
value, ok := source.(string)
|
||||||
|
if !ok || !contextFieldPattern(value) {
|
||||||
|
return fmt.Errorf("规则 %s 的 value_from 格式不正确", rule.RuleKey)
|
||||||
|
}
|
||||||
|
valuePresent = true
|
||||||
|
}
|
||||||
|
if !valuePresent {
|
||||||
|
return fmt.Errorf("规则 %s 的动作缺少 value", rule.RuleKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateContractCondition(value interface{}, depth int) error {
|
||||||
|
if depth > 12 {
|
||||||
|
return fmt.Errorf("条件嵌套层级过深")
|
||||||
|
}
|
||||||
|
rule, ok := value.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("条件必须是对象")
|
||||||
|
}
|
||||||
|
groups := 0
|
||||||
|
for _, key := range []string{"all", "any"} {
|
||||||
|
if raw, exists := rule[key]; exists {
|
||||||
|
groups++
|
||||||
|
items, ok := raw.([]interface{})
|
||||||
|
if !ok || len(items) == 0 || len(items) > 100 {
|
||||||
|
return fmt.Errorf("%s 条件必须是非空数组且最多包含 100 项", key)
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if err := validateContractCondition(item, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if groups > 1 {
|
||||||
|
return fmt.Errorf("条件不能同时包含 all 和 any")
|
||||||
|
}
|
||||||
|
if groups == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
field, _ := rule["field"].(string)
|
||||||
|
operator, _ := rule["operator"].(string)
|
||||||
|
allowedOperators := map[string]bool{"equals": true, "not_equals": true, "contains": true, "greater_than": true, "less_than": true, "exists": true, "not_exists": true, "in": true}
|
||||||
|
if !contextFieldPattern(field) {
|
||||||
|
return fmt.Errorf("条件字段格式不正确")
|
||||||
|
}
|
||||||
|
if !allowedOperators[operator] {
|
||||||
|
return fmt.Errorf("不支持的条件运算符 %s", operator)
|
||||||
|
}
|
||||||
|
if operator != "exists" && operator != "not_exists" {
|
||||||
|
if _, exists := rule["value"]; !exists {
|
||||||
|
return fmt.Errorf("条件缺少 value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextFieldPattern(value string) bool {
|
||||||
|
if fieldKeyPattern.MatchString(value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"input.", "derived.", "form."} {
|
||||||
|
if strings.HasPrefix(value, prefix) && fieldKeyPattern.MatchString(strings.TrimPrefix(value, prefix)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contractScenarioID(c *gin.Context) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
71
internal/scenario/contract_test.go
Normal file
71
internal/scenario/contract_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package scenario
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateContractInput(t *testing.T) {
|
||||||
|
valid := contractInput{
|
||||||
|
OutputSchema: map[string]interface{}{"fields": []interface{}{
|
||||||
|
map[string]interface{}{"key": "matched_topics", "source": "derived", "source_field": "topics"},
|
||||||
|
}},
|
||||||
|
ResultSchema: map[string]interface{}{"fields": []interface{}{
|
||||||
|
map[string]interface{}{"key": "selected_products", "name": "成交商品", "type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||||
|
}},
|
||||||
|
Rules: []ruleInput{{
|
||||||
|
RuleKey: "match_topic", Name: "匹配主题",
|
||||||
|
Condition: map[string]interface{}{"field": "product_tags", "operator": "contains", "value": "hot"},
|
||||||
|
Actions: []map[string]interface{}{{"operation": "append", "field": "topics", "value": "topic_hot"}},
|
||||||
|
}},
|
||||||
|
AllowedOrigins: []string{"https://crm.example.com"},
|
||||||
|
}
|
||||||
|
if err := validateContractInput(valid); err != nil {
|
||||||
|
t.Fatalf("valid contract rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
edit func(*contractInput)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "duplicate output", edit: func(input *contractInput) {
|
||||||
|
input.OutputSchema["fields"] = append(input.OutputSchema["fields"].([]interface{}), map[string]interface{}{"key": "matched_topics"})
|
||||||
|
}, want: "输出字段标识"},
|
||||||
|
{name: "invalid source", edit: func(input *contractInput) {
|
||||||
|
input.OutputSchema["fields"].([]interface{})[0].(map[string]interface{})["source"] = "script"
|
||||||
|
}, want: "不支持的来源"},
|
||||||
|
{name: "invalid condition", edit: func(input *contractInput) { input.Rules[0].Condition = map[string]interface{}{"all": []interface{}{}} }, want: "非空数组"},
|
||||||
|
{name: "invalid action", edit: func(input *contractInput) { input.Rules[0].Actions[0]["operation"] = "execute" }, want: "不支持的动作"},
|
||||||
|
{name: "invalid origin", edit: func(input *contractInput) { input.AllowedOrigins = []string{"crm.example.com"} }, want: "完整的 http/https Origin"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := cloneContractInput(valid)
|
||||||
|
test.edit(&input)
|
||||||
|
if err := validateContractInput(input); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("error = %v, want containing %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneContractInput(input contractInput) contractInput {
|
||||||
|
field := input.OutputSchema["fields"].([]interface{})[0].(map[string]interface{})
|
||||||
|
fieldCopy := map[string]interface{}{}
|
||||||
|
for key, value := range field {
|
||||||
|
fieldCopy[key] = value
|
||||||
|
}
|
||||||
|
rule := input.Rules[0]
|
||||||
|
condition := map[string]interface{}{}
|
||||||
|
for key, value := range rule.Condition {
|
||||||
|
condition[key] = value
|
||||||
|
}
|
||||||
|
action := map[string]interface{}{}
|
||||||
|
for key, value := range rule.Actions[0] {
|
||||||
|
action[key] = value
|
||||||
|
}
|
||||||
|
rule.Condition = condition
|
||||||
|
rule.Actions = []map[string]interface{}{action}
|
||||||
|
return contractInput{OutputSchema: map[string]interface{}{"fields": []interface{}{fieldCopy}}, ResultSchema: input.ResultSchema, Rules: []ruleInput{rule}, AllowedOrigins: append([]string{}, input.AllowedOrigins...)}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -33,12 +34,15 @@ type scenarioInput struct {
|
|||||||
Goal string `json:"goal" binding:"required,max=2000"`
|
Goal string `json:"goal" binding:"required,max=2000"`
|
||||||
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
|
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
|
||||||
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
|
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
|
||||||
|
OutputSchema json.RawMessage `json:"output_schema"`
|
||||||
|
ResultSchema json.RawMessage `json:"result_schema"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type fieldInput struct {
|
type fieldInput struct {
|
||||||
FieldKey string `json:"field_key" binding:"required,max=64"`
|
FieldKey string `json:"field_key" binding:"required,max=64"`
|
||||||
FieldName string `json:"field_name" binding:"required,max=128"`
|
FieldName string `json:"field_name" binding:"required,max=128"`
|
||||||
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect date"`
|
SourcePath string `json:"source_path" binding:"max=255"`
|
||||||
|
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect array date"`
|
||||||
Required bool `json:"required"`
|
Required bool `json:"required"`
|
||||||
Options json.RawMessage `json:"options"`
|
Options json.RawMessage `json:"options"`
|
||||||
Validation json.RawMessage `json:"validation"`
|
Validation json.RawMessage `json:"validation"`
|
||||||
@@ -46,6 +50,24 @@ type fieldInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||||
|
var sourcePathPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(?:\.(?:[A-Za-z][A-Za-z0-9_]*|\*)|\[(?:\d+|\*)\])*$`)
|
||||||
|
|
||||||
|
func buildInputSchema(fields []model.ScenarioField) datatypes.JSON {
|
||||||
|
items := make([]gin.H, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
items = append(items, gin.H{"key": field.FieldKey, "name": field.FieldName, "type": field.FieldType, "source_path": field.SourcePath, "required": field.Required, "options": field.Options, "validation": field.Validation})
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(gin.H{"fields": items})
|
||||||
|
return datatypes.JSON(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncInputSchema(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||||
|
fields := make([]model.ScenarioField, 0)
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order, id").Find(&fields).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Update("input_schema", buildInputSchema(fields)).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
func (h *Handler) List(c *gin.Context) {
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
@@ -80,7 +102,7 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
if visibility == "" {
|
if visibility == "" {
|
||||||
visibility = "tenant"
|
visibility = "tenant"
|
||||||
}
|
}
|
||||||
item := model.Scenario{TenantID: p.TenantID, Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID}
|
item := model.Scenario{TenantID: p.TenantID, ScenarioKey: "scenario-" + uuid.NewString(), PublicKey: "pk_" + uuid.NewString(), AllowedOrigins: datatypes.JSON([]byte(`[]`)), Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID, InputSchema: datatypes.JSON([]byte(`{"fields":[]}`)), OutputSchema: normalizedJSON(input.OutputSchema, `{"fields":[]}`), ResultSchema: normalizedJSON(input.ResultSchema, `{"fields":[]}`)}
|
||||||
if err := h.db.Create(&item).Error; err != nil {
|
if err := h.db.Create(&item).Error; err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败")
|
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败")
|
||||||
return
|
return
|
||||||
@@ -107,6 +129,7 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
fields := make([]model.ScenarioField, 0)
|
fields := make([]model.ScenarioField, 0)
|
||||||
sops := make([]model.SOP, 0)
|
sops := make([]model.SOP, 0)
|
||||||
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields)
|
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields)
|
||||||
|
item.InputSchema = buildInputSchema(fields)
|
||||||
if auth.HasPermission(p, "sop.view") {
|
if auth.HasPermission(p, "sop.view") {
|
||||||
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
|
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
|
||||||
}
|
}
|
||||||
@@ -133,6 +156,12 @@ func (h *Handler) Update(c *gin.Context) {
|
|||||||
visibility = "tenant"
|
visibility = "tenant"
|
||||||
}
|
}
|
||||||
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": visibility}
|
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": visibility}
|
||||||
|
if len(input.OutputSchema) > 0 {
|
||||||
|
updates["output_schema"] = normalizedJSON(input.OutputSchema, `{"fields":[]}`)
|
||||||
|
}
|
||||||
|
if len(input.ResultSchema) > 0 {
|
||||||
|
updates["result_schema"] = normalizedJSON(input.ResultSchema, `{"fields":[]}`)
|
||||||
|
}
|
||||||
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").Updates(updates)
|
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").Updates(updates)
|
||||||
if result.Error != nil || result.RowsAffected == 0 {
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
@@ -152,15 +181,6 @@ func (h *Handler) Archive(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可归档")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可归档")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var activeSOPs int64
|
|
||||||
if err := h.db.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"published", "reviewing"}).Count(&activeSOPs).Error; err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查场景状态失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if activeSOPs > 0 {
|
|
||||||
response.Error(c, http.StatusConflict, "SCENARIO_IN_USE", "请先下线已发布 SOP 或处理审核任务")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error; err != nil {
|
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -193,11 +213,15 @@ func (h *Handler) CreateField(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
|
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, SourcePath: input.SourcePath, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
|
||||||
if err := h.db.Create(&item).Error; err != nil {
|
if err := h.db.Create(&item).Error; err != nil {
|
||||||
response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确")
|
response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := syncInputSchema(h.db, p.TenantID, scenarioID); err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
_ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input)
|
_ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input)
|
||||||
response.Created(c, item)
|
response.Created(c, item)
|
||||||
}
|
}
|
||||||
@@ -214,7 +238,7 @@ func (h *Handler) UpdateField(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
|
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
|
||||||
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能修改;请新增字段并创建 SOP 新版本")
|
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被当前 SOP 引用,不能修改;请先调整 SOP")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var input fieldInput
|
var input fieldInput
|
||||||
@@ -226,12 +250,16 @@ func (h *Handler) UpdateField(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
|
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "source_path": input.SourcePath, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
|
||||||
result := h.db.Model(&model.ScenarioField{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(updates)
|
result := h.db.Model(&model.ScenarioField{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(updates)
|
||||||
if result.Error != nil || result.RowsAffected == 0 {
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := syncInputSchema(h.db, p.TenantID, existing.ScenarioID); err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
_ = audit.Record(h.db, p, "update", "scenario_field", id, input)
|
_ = audit.Record(h.db, p, "update", "scenario_field", id, input)
|
||||||
var item model.ScenarioField
|
var item model.ScenarioField
|
||||||
h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item)
|
h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item)
|
||||||
@@ -250,7 +278,7 @@ func (h *Handler) DeleteField(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
|
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
|
||||||
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能删除")
|
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被当前 SOP 引用,不能删除")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{})
|
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{})
|
||||||
@@ -258,7 +286,21 @@ func (h *Handler) DeleteField(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = audit.Record(h.db, p, "delete", "scenario_field", id, nil)
|
if err := syncInputSchema(h.db, p.TenantID, existing.ScenarioID); err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "delete", "scenario_field", id, gin.H{
|
||||||
|
"scenario_id": existing.ScenarioID,
|
||||||
|
"field_key": existing.FieldKey,
|
||||||
|
"field_name": existing.FieldName,
|
||||||
|
"source_path": existing.SourcePath,
|
||||||
|
"field_type": existing.FieldType,
|
||||||
|
"required": existing.Required,
|
||||||
|
"options": json.RawMessage(existing.Options),
|
||||||
|
"validation": json.RawMessage(existing.Validation),
|
||||||
|
"sort_order": existing.SortOrder,
|
||||||
|
})
|
||||||
response.OK(c, gin.H{"id": id})
|
response.OK(c, gin.H{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +315,9 @@ func validateFieldInput(input fieldInput) error {
|
|||||||
if !fieldKeyPattern.MatchString(input.FieldKey) {
|
if !fieldKeyPattern.MatchString(input.FieldKey) {
|
||||||
return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线")
|
return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线")
|
||||||
}
|
}
|
||||||
|
if input.SourcePath != "" && !sourcePathPattern.MatchString(input.SourcePath) {
|
||||||
|
return errors.New("数据路径格式不正确,例如 customer.name 或 order.items[*].product_id")
|
||||||
|
}
|
||||||
if len(input.Options) > 0 {
|
if len(input.Options) > 0 {
|
||||||
var options []string
|
var options []string
|
||||||
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {
|
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -54,6 +52,10 @@ type edgeInput struct {
|
|||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type editState struct {
|
||||||
|
StartNodeKey string `json:"start_node_key"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
func (h *Handler) List(c *gin.Context) {
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
items := make([]model.SOP, 0)
|
items := make([]model.SOP, 0)
|
||||||
@@ -96,11 +98,11 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
var item model.SOP
|
var item model.SOP
|
||||||
var version model.SOPVersion
|
var version model.SOPVersion
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "draft", CreatedBy: p.UserID}
|
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "published", CreatedBy: p.UserID}
|
||||||
if err := tx.Create(&item).Error; err != nil {
|
if err := tx.Create(&item).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "draft", StartNodeKey: "start", CreatedBy: p.UserID}
|
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: p.UserID}
|
||||||
if err := tx.Create(&version).Error; err != nil {
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -116,14 +118,17 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||||
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||||
}
|
}
|
||||||
return tx.Create(&edges).Error
|
if err := tx.Create(&edges).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).Update("status", "active").Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
|
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
|
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
|
||||||
response.Created(c, gin.H{"sop": item, "version": version})
|
response.Created(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Get(c *gin.Context) {
|
func (h *Handler) Get(c *gin.Context) {
|
||||||
@@ -140,17 +145,7 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
var version model.SOPVersion
|
var version model.SOPVersion
|
||||||
var nodes []model.SOPNode
|
var nodes []model.SOPNode
|
||||||
var edges []model.SOPEdge
|
var edges []model.SOPEdge
|
||||||
var err error
|
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||||
if requested := c.Query("version"); requested != "" {
|
|
||||||
versionNumber, parseErr := strconv.Atoi(requested)
|
|
||||||
if parseErr != nil || versionNumber < 1 {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
|
|
||||||
} else {
|
|
||||||
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
@@ -159,36 +154,7 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
|
response.OK(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}, "nodes": nodes, "edges": edges})
|
||||||
}
|
|
||||||
|
|
||||||
type versionItem struct {
|
|
||||||
model.SOPVersion
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
ReviewerName string `json:"reviewer_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ListVersions(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, ok := parseID(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanViewSOP(h.db, p, id) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]versionItem, 0)
|
|
||||||
err := h.db.Table("sop_versions sv").Select("sv.*, creator.display_name AS creator_name, COALESCE(reviewer.display_name, '') AS reviewer_name").
|
|
||||||
Joins("JOIN users creator ON creator.id = sv.created_by").
|
|
||||||
Joins("LEFT JOIN users reviewer ON reviewer.id = sv.reviewed_by").
|
|
||||||
Where("sv.sop_id = ? AND sv.tenant_id = ?", id, p.TenantID).
|
|
||||||
Order("sv.version DESC").Scan(&items).Error
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询版本历史失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) SaveGraph(c *gin.Context) {
|
func (h *Handler) SaveGraph(c *gin.Context) {
|
||||||
@@ -206,18 +172,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var version model.SOPVersion
|
item, version, _, _, err := h.loadLatest(id, p.TenantID)
|
||||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
nodes, edges := toModels(p.TenantID, version.ID, input)
|
nodes, edges := toModels(p.TenantID, version.ID, input)
|
||||||
problems := ValidateGraph(input.StartNodeKey, nodes, edges)
|
problems, validationErr := h.validateForPublish(item, input.StartNodeKey, nodes, edges, p.TenantID)
|
||||||
|
if validationErr != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -232,13 +202,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tx.Model(&version).Update("start_node_key", input.StartNodeKey).Error
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND id <> ?", id, p.TenantID, version.ID).Update("status", "superseded").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&version).Updates(map[string]interface{}{"start_node_key": input.StartNodeKey, "status": "published"}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
|
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = audit.Record(h.db, p, "save_graph", "sop", id, gin.H{"version_id": version.ID})
|
_ = audit.Record(h.db, p, "save_graph", "sop", id, nil)
|
||||||
h.Get(c)
|
h.Get(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,17 +235,7 @@ func (h *Handler) Validate(c *gin.Context) {
|
|||||||
var version model.SOPVersion
|
var version model.SOPVersion
|
||||||
var nodes []model.SOPNode
|
var nodes []model.SOPNode
|
||||||
var edges []model.SOPEdge
|
var edges []model.SOPEdge
|
||||||
var err error
|
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||||
if requested := c.Query("version"); requested != "" {
|
|
||||||
versionNumber, parseErr := strconv.Atoi(requested)
|
|
||||||
if parseErr != nil || versionNumber < 1 {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
|
|
||||||
} else {
|
|
||||||
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
return
|
return
|
||||||
@@ -279,273 +248,6 @@ func (h *Handler) Validate(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
|
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) SubmitReview(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, ok := parseID(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanEditSOP(h.db, p, id) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可提交")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
|
||||||
if err != nil || version.Status != "draft" {
|
|
||||||
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
|
|
||||||
if validationErr != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var published int64
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Count(&published).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if published == 0 {
|
|
||||||
return tx.Model(&item).Update("status", "reviewing").Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "SUBMIT_REVIEW_FAILED", "提交审核失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "submit_review", "sop", id, gin.H{"version": version.Version})
|
|
||||||
response.OK(c, gin.H{"id": id, "version": version.Version, "status": "reviewing"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Publish(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, ok := parseID(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanViewSOP(h.db, p, id) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
|
||||||
canDirectPublish := auth.HasPermission(p, "*")
|
|
||||||
if err != nil || (version.Status != "reviewing" && !(version.Status == "draft" && canDirectPublish)) {
|
|
||||||
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !canDirectPublish && version.CreatedBy == p.UserID {
|
|
||||||
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核并发布自己创建的 SOP")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
|
|
||||||
if validationErr != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := BindKnowledgeVersions(tx, p.TenantID, version.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "published", "published_at": &now, "reviewed_by": p.UserID}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "PUBLISH_FAILED", "发布 SOP 失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "publish", "sop", id, gin.H{"version": version.Version})
|
|
||||||
response.OK(c, gin.H{"id": id, "version": version.Version, "published_at": now})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Offline(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, ok := parseID(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanViewSOP(h.db, p, id) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var item model.SOP
|
|
||||||
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").First(&item).Error; err != nil {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&item).Update("status", "offline").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "offline").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var published int64
|
|
||||||
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, p.TenantID, "published").Count(&published).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if published == 0 {
|
|
||||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "draft").Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "OFFLINE_FAILED", "下线 SOP 失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "offline", "sop", id, nil)
|
|
||||||
response.OK(c, gin.H{"id": id, "status": "offline"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateVersion(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, ok := parseID(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanEditSOP(h.db, p, id) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可编辑")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var existing int64
|
|
||||||
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&existing)
|
|
||||||
if existing > 0 {
|
|
||||||
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿或审核中的版本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var version model.SOPVersion
|
|
||||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
version = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: source.Version + 1, Status: "draft", StartNodeKey: source.StartNodeKey, CreatedBy: p.UserID}
|
|
||||||
if err := tx.Create(&version).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for i := range nodes {
|
|
||||||
nodes[i].Base = model.Base{}
|
|
||||||
nodes[i].SOPVersionID = version.ID
|
|
||||||
}
|
|
||||||
for i := range edges {
|
|
||||||
edges[i].Base = model.Base{}
|
|
||||||
edges[i].SOPVersionID = version.ID
|
|
||||||
}
|
|
||||||
if err := tx.Create(&nodes).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(edges) > 0 {
|
|
||||||
return tx.Create(&edges).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "CREATE_VERSION_FAILED", "创建草稿版本失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "create_version", "sop", id, gin.H{"version": version.Version})
|
|
||||||
response.Created(c, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Rollback(c *gin.Context) {
|
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
|
||||||
id, ok := parseID(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanViewSOP(h.db, p, id) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Version int `json:"version" binding:"required,min=1"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要回滚的历史版本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, source, nodes, edges, err := h.loadVersion(id, p.TenantID, input.Version)
|
|
||||||
if err != nil || (source.Status != "superseded" && source.Status != "offline") {
|
|
||||||
response.Error(c, http.StatusConflict, "INVALID_ROLLBACK_VERSION", "只能回滚到已替换或已下线的历史版本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
problems, validationErr := h.validateForPublish(item, source.StartNodeKey, nodes, edges, p.TenantID)
|
|
||||||
if validationErr != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验历史版本失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
var restored model.SOPVersion
|
|
||||||
pendingVersionErr := errors.New("存在草稿或审核中的版本,请先处理后再回滚")
|
|
||||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var locked model.SOP
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&locked).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var pending int64
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&pending).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if pending > 0 {
|
|
||||||
return pendingVersionErr
|
|
||||||
}
|
|
||||||
var maxVersion int
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reviewerID := p.UserID
|
|
||||||
restored = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: maxVersion + 1, Status: "published", StartNodeKey: source.StartNodeKey, PublishedAt: &now, CreatedBy: p.UserID, ReviewedBy: &reviewerID}
|
|
||||||
if err := tx.Create(&restored).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := cloneGraph(tx, nodes, edges, restored.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&locked).Update("status", "published").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", locked.ScenarioID, p.TenantID).Update("status", "active").Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pendingVersionErr) {
|
|
||||||
response.Error(c, http.StatusConflict, "PENDING_VERSION_EXISTS", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Error(c, http.StatusInternalServerError, "ROLLBACK_FAILED", "回滚 SOP 失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, p, "rollback", "sop", id, gin.H{"source_version": source.Version, "new_version": restored.Version})
|
|
||||||
response.Created(c, gin.H{"id": id, "source_version": source.Version, "version": restored.Version, "published_at": now})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
||||||
var item model.SOP
|
var item model.SOP
|
||||||
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
||||||
@@ -566,60 +268,20 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
|
|||||||
return item, version, nodes, edges, nil
|
return item, version, nodes, edges, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) loadVersion(sopID, tenantID uint64, versionNumber int) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
|
||||||
var item model.SOP
|
|
||||||
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
|
||||||
return item, model.SOPVersion{}, nil, nil, err
|
|
||||||
}
|
|
||||||
var version model.SOPVersion
|
|
||||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND version = ?", sopID, tenantID, versionNumber).First(&version).Error; err != nil {
|
|
||||||
return item, version, nil, nil, err
|
|
||||||
}
|
|
||||||
nodes := make([]model.SOPNode, 0)
|
|
||||||
edges := make([]model.SOPEdge, 0)
|
|
||||||
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
|
|
||||||
return item, version, nil, nil, err
|
|
||||||
}
|
|
||||||
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
|
|
||||||
return item, version, nil, nil, err
|
|
||||||
}
|
|
||||||
return item, version, nodes, edges, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneGraph(tx *gorm.DB, nodes []model.SOPNode, edges []model.SOPEdge, versionID uint64) error {
|
|
||||||
for i := range nodes {
|
|
||||||
nodes[i].Base = model.Base{}
|
|
||||||
nodes[i].SOPVersionID = versionID
|
|
||||||
}
|
|
||||||
for i := range edges {
|
|
||||||
edges[i].Base = model.Base{}
|
|
||||||
edges[i].SOPVersionID = versionID
|
|
||||||
}
|
|
||||||
if len(nodes) > 0 {
|
|
||||||
if err := tx.Create(&nodes).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(edges) > 0 {
|
|
||||||
return tx.Create(&edges).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
||||||
fields := make([]model.ScenarioField, 0)
|
fields := make([]model.ScenarioField, 0)
|
||||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var cards []model.KnowledgeCard
|
var knowledgeItems []model.KnowledgeItem
|
||||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, tenantID, "published").Find(&cards).Error; err != nil {
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeItems).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cardIDs := make(map[uint64]bool, len(cards))
|
var knowledgeRelations []model.KnowledgeRelation
|
||||||
for _, card := range cards {
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeRelations).Error; err != nil {
|
||||||
cardIDs[card.ID] = true
|
return nil, err
|
||||||
}
|
}
|
||||||
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, PublishedKnowledgeCardIDs: cardIDs}), nil
|
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, KnowledgeItems: knowledgeItems, KnowledgeRelations: knowledgeRelations}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
package sop
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type knowledgeNodeConfig struct {
|
|
||||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
|
||||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindKnowledgeVersions freezes the current published knowledge-card version
|
|
||||||
// into every knowledge node before the SOP version becomes immutable.
|
|
||||||
func BindKnowledgeVersions(tx *gorm.DB, tenantID, sopVersionID uint64) error {
|
|
||||||
nodes := make([]model.SOPNode, 0)
|
|
||||||
if err := tx.Where("tenant_id = ? AND sop_version_id = ? AND type = ?", tenantID, sopVersionID, "knowledge").Find(&nodes).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, node := range nodes {
|
|
||||||
var config knowledgeNodeConfig
|
|
||||||
if err := json.Unmarshal(node.Config, &config); err != nil || config.KnowledgeCardID == 0 {
|
|
||||||
return fmt.Errorf("knowledge node %s has invalid configuration", node.NodeKey)
|
|
||||||
}
|
|
||||||
var version model.KnowledgeCardVersion
|
|
||||||
err := tx.Table("knowledge_card_versions kv").Select("kv.*").
|
|
||||||
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
|
|
||||||
Where("kv.tenant_id = ? AND kv.knowledge_card_id = ? AND kv.status = ? AND kc.tenant_id = ? AND kc.status = ?", tenantID, config.KnowledgeCardID, "published", tenantID, "published").
|
|
||||||
Order("kv.version DESC").First(&version).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("knowledge node %s has no published card version: %w", node.NodeKey, err)
|
|
||||||
}
|
|
||||||
updated, err := withKnowledgeVersion(node.Config, version.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update knowledge node %s: %w", node.NodeKey, err)
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.SOPNode{}).Where("id = ? AND tenant_id = ? AND sop_version_id = ?", node.ID, tenantID, sopVersionID).Update("config", updated).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func withKnowledgeVersion(config datatypes.JSON, versionID uint64) (datatypes.JSON, error) {
|
|
||||||
value := map[string]interface{}{}
|
|
||||||
if len(config) > 0 {
|
|
||||||
if err := json.Unmarshal(config, &value); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
value["knowledge_card_version_id"] = versionID
|
|
||||||
encoded, err := json.Marshal(value)
|
|
||||||
return datatypes.JSON(encoded), err
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package sop
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWithKnowledgeVersionPreservesConfiguration(t *testing.T) {
|
|
||||||
updated, err := withKnowledgeVersion(datatypes.JSON([]byte(`{"knowledge_card_id":12,"display":"full"}`)), 34)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var value map[string]interface{}
|
|
||||||
if err := json.Unmarshal(updated, &value); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if value["knowledge_card_id"] != float64(12) || value["knowledge_card_version_id"] != float64(34) || value["display"] != "full" {
|
|
||||||
t.Fatalf("unexpected knowledge config: %#v", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package sop
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReviewItem struct {
|
|
||||||
SOPID uint64 `json:"sop_id"`
|
|
||||||
SOPName string `json:"sop_name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
ScenarioID uint64 `json:"scenario_id"`
|
|
||||||
ScenarioName string `json:"scenario_name"`
|
|
||||||
VersionID uint64 `json:"version_id"`
|
|
||||||
Version int `json:"version"`
|
|
||||||
CreatorID uint64 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
SubmittedAt time.Time `json:"submitted_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Reviews(c *gin.Context) {
|
|
||||||
principal, _ := auth.PrincipalFromContext(c)
|
|
||||||
items := make([]ReviewItem, 0)
|
|
||||||
query := h.db.Table("sop_versions sv").Select(
|
|
||||||
"s.id AS sop_id, s.name AS sop_name, s.description, sc.id AS scenario_id, sc.name AS scenario_name, " +
|
|
||||||
"sv.id AS version_id, sv.version, sv.created_by AS creator_id, u.display_name AS creator_name, sv.updated_at AS submitted_at",
|
|
||||||
).Joins("JOIN sops s ON s.id = sv.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN users u ON u.id = sv.created_by")
|
|
||||||
query = access.ScopeScenarios(query, principal, "sc")
|
|
||||||
if err := query.Where("sv.tenant_id = ? AND sv.status = ?", principal.TenantID, "reviewing").Order("sv.updated_at ASC").Scan(&items).Error; err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询审核任务失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Reject(c *gin.Context) {
|
|
||||||
principal, _ := auth.PrincipalFromContext(c)
|
|
||||||
sopID, ok := parseID(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !access.CanViewSOP(h.db, principal, sopID) {
|
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Reason string `json:"reason" binding:"required,max=1000"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请填写退回原因")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var version model.SOPVersion
|
|
||||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "reviewing").Order("version DESC").First(&version).Error; err != nil {
|
|
||||||
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有待审核版本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !auth.HasPermission(principal, "*") && version.CreatedBy == principal.UserID {
|
|
||||||
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核自己创建的 SOP")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "draft", "reviewed_by": nil}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var published int64
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "published").Count(&published).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
status := "draft"
|
|
||||||
if published > 0 {
|
|
||||||
status = "published"
|
|
||||||
}
|
|
||||||
return tx.Model(&model.SOP{}).Where("id = ? AND tenant_id = ?", sopID, principal.TenantID).Update("status", status).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, http.StatusInternalServerError, "REJECT_FAILED", "退回审核失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = audit.Record(h.db, principal, "reject", "sop", sopID, gin.H{"version": version.Version, "reason": input.Reason})
|
|
||||||
response.OK(c, gin.H{"id": sopID, "version": version.Version, "status": "draft"})
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,8 @@ var allowedConditionOperators = map[string]bool{"equals": true, "not_equals": tr
|
|||||||
|
|
||||||
type ValidationContext struct {
|
type ValidationContext struct {
|
||||||
Fields []model.ScenarioField
|
Fields []model.ScenarioField
|
||||||
PublishedKnowledgeCardIDs map[uint64]bool
|
KnowledgeItems []model.KnowledgeItem
|
||||||
|
KnowledgeRelations []model.KnowledgeRelation
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
|
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
|
||||||
@@ -134,6 +135,23 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
|||||||
for _, field := range context.Fields {
|
for _, field := range context.Fields {
|
||||||
fieldMap[field.FieldKey] = field
|
fieldMap[field.FieldKey] = field
|
||||||
}
|
}
|
||||||
|
knowledgeByKey := make(map[string]model.KnowledgeItem, len(context.KnowledgeItems))
|
||||||
|
knowledgeTypes := make(map[string]bool)
|
||||||
|
knowledgeIDs := make(map[uint64]bool, len(context.KnowledgeItems))
|
||||||
|
for _, item := range context.KnowledgeItems {
|
||||||
|
if item.Status != "active" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
knowledgeByKey[item.ItemKey] = item
|
||||||
|
knowledgeTypes[item.Type] = true
|
||||||
|
knowledgeIDs[item.ID] = true
|
||||||
|
}
|
||||||
|
relationTypes := make(map[string]bool)
|
||||||
|
for _, relation := range context.KnowledgeRelations {
|
||||||
|
if knowledgeIDs[relation.FromKnowledgeID] && knowledgeIDs[relation.ToKnowledgeID] {
|
||||||
|
relationTypes[relation.RelationType] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
collected := map[string]bool{}
|
collected := map[string]bool{}
|
||||||
nodeMap := make(map[string]model.SOPNode, len(nodes))
|
nodeMap := make(map[string]model.SOPNode, len(nodes))
|
||||||
adjacency := make(map[string][]string)
|
adjacency := make(map[string][]string)
|
||||||
@@ -174,14 +192,38 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
|||||||
collected[fieldKey] = true
|
collected[fieldKey] = true
|
||||||
}
|
}
|
||||||
case "knowledge":
|
case "knowledge":
|
||||||
cardID := uint64FromJSON(config["knowledge_card_id"])
|
selector, ok := config["knowledge_selector"].(map[string]interface{})
|
||||||
if cardID == 0 || !context.PublishedKnowledgeCardIDs[cardID] {
|
if !ok {
|
||||||
problems = append(problems, fmt.Sprintf("节点“%s”没有关联已发布的知识卡", node.Title))
|
problems = append(problems, fmt.Sprintf("节点“%s”没有配置知识选择器", node.Title))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
validateKnowledgeSelector(node, selector, knowledgeByKey, knowledgeTypes, relationTypes, &problems)
|
||||||
|
if collection, ok := config["knowledge_collection"].(map[string]interface{}); ok {
|
||||||
|
if keys, ok := collection["context_field_keys"].([]interface{}); ok {
|
||||||
|
for _, value := range keys {
|
||||||
|
if key, ok := value.(string); ok {
|
||||||
|
if _, exists := fieldMap[key]; !exists {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”引用的采集字段 %s 不存在", node.Title, key))
|
||||||
|
} else {
|
||||||
|
collected[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if steps, ok := collection["steps"].([]interface{}); ok {
|
||||||
|
for _, raw := range steps {
|
||||||
|
if step, ok := raw.(map[string]interface{}); ok {
|
||||||
|
if key, ok := step["field_key"].(string); ok && key != "" {
|
||||||
|
collected[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, field := range context.Fields {
|
for _, field := range context.Fields {
|
||||||
if field.Required && !collected[field.FieldKey] {
|
if field.Required && field.SourcePath == "" && !collected[field.FieldKey] {
|
||||||
problems = append(problems, fmt.Sprintf("必填字段“%s”没有对应的采集节点", field.FieldName))
|
problems = append(problems, fmt.Sprintf("必填字段“%s”没有对应的采集节点", field.FieldName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,20 +354,61 @@ func isDefaultCondition(value []byte) bool {
|
|||||||
return ok && len(object) == 0
|
return ok && len(object) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func uint64FromJSON(value interface{}) uint64 {
|
func validateKnowledgeSelector(node model.SOPNode, selector map[string]interface{}, items map[string]model.KnowledgeItem, availableTypes, availableRelations map[string]bool, problems *[]string) {
|
||||||
switch typed := value.(type) {
|
derivedField, _ := selector["derived_field"].(string)
|
||||||
case float64:
|
answerField, _ := selector["answer_field"].(string)
|
||||||
if typed > 0 {
|
keys, keysValid := selectorStrings(selector, "knowledge_keys")
|
||||||
return uint64(typed)
|
types, typesValid := selectorStrings(selector, "knowledge_types")
|
||||||
|
relations, relationsValid := selectorStrings(selector, "relation_types")
|
||||||
|
if !keysValid || !typesValid || !relationsValid {
|
||||||
|
*problems = append(*problems, fmt.Sprintf("节点“%s”的知识选择器必须使用字符串数组", node.Title))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
case uint64:
|
if derivedField == "" && answerField == "" && len(keys) == 0 {
|
||||||
return typed
|
*problems = append(*problems, fmt.Sprintf("节点“%s”没有配置派生知识字段或固定知识 key", node.Title))
|
||||||
case int:
|
}
|
||||||
if typed > 0 {
|
allowedTypes := make(map[string]bool, len(types))
|
||||||
return uint64(typed)
|
for _, itemType := range types {
|
||||||
|
allowedTypes[itemType] = true
|
||||||
|
if !availableTypes[itemType] {
|
||||||
|
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识类型 %s 不存在", node.Title, itemType))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
for _, key := range keys {
|
||||||
|
item, exists := items[key]
|
||||||
|
if !exists {
|
||||||
|
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识 key %s 不存在或未启用", node.Title, key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(allowedTypes) > 0 && !allowedTypes[item.Type] {
|
||||||
|
*problems = append(*problems, fmt.Sprintf("节点“%s”的知识 key %s 不属于允许的根类型", node.Title, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, relationType := range relations {
|
||||||
|
if !availableRelations[relationType] {
|
||||||
|
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识关系类型 %s 不存在", node.Title, relationType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectorStrings(selector map[string]interface{}, key string) ([]string, bool) {
|
||||||
|
raw, exists := selector[key]
|
||||||
|
if !exists || raw == nil {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
values, ok := raw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok || text == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool {
|
func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool {
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ func TestValidateForPublishValidHighRiskFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateForPublishAllowsRequiredExternalInput(t *testing.T) {
|
||||||
|
nodes, edges, context := validPublishGraph()
|
||||||
|
context.Fields = append(context.Fields, model.ScenarioField{FieldKey: "order_id", FieldName: "订单号", SourcePath: "order.id", Required: true})
|
||||||
|
if problems := ValidateForPublish("start", nodes, edges, context); len(problems) != 0 {
|
||||||
|
t.Fatalf("required external input should not need a collection node: %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateForPublishBusinessRules(t *testing.T) {
|
func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -30,9 +38,15 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
|||||||
{name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
{name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`)
|
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`)
|
||||||
}, want: "不支持的运算符 matches"},
|
}, want: "不支持的运算符 matches"},
|
||||||
{name: "unpublished knowledge", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
|
{name: "missing knowledge key", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
|
||||||
context.PublishedKnowledgeCardIDs = map[uint64]bool{}
|
context.KnowledgeItems = nil
|
||||||
}, want: "没有关联已发布的知识卡"},
|
}, want: "知识 key safety 不存在或未启用"},
|
||||||
|
{name: "missing knowledge type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
|
(*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["missing"],"relation_types":["related_copy"]}}`)
|
||||||
|
}, want: "知识类型 missing 不存在"},
|
||||||
|
{name: "missing relation type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
|
(*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["missing"]}}`)
|
||||||
|
}, want: "知识关系类型 missing 不存在"},
|
||||||
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
(*edges)[1].Condition = jsonData(`{}`)
|
(*edges)[1].Condition = jsonData(`{}`)
|
||||||
}, want: "配置了多条默认路径"},
|
}, want: "配置了多条默认路径"},
|
||||||
@@ -109,7 +123,7 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
|
|||||||
nodes := []model.SOPNode{
|
nodes := []model.SOPNode{
|
||||||
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||||
{NodeKey: "screen", Type: "form", Title: "急症筛查", Config: jsonData(`{"field_keys":["emergency"],"risk_level":"high"}`)},
|
{NodeKey: "screen", Type: "form", Title: "急症筛查", Config: jsonData(`{"field_keys":["emergency"],"risk_level":"high"}`)},
|
||||||
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_card_id":1}`)},
|
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["related_copy"]}}`)},
|
||||||
{NodeKey: "escalate", Type: "escalate", Title: "转诊", Config: jsonData(`{}`)},
|
{NodeKey: "escalate", Type: "escalate", Title: "转诊", Config: jsonData(`{}`)},
|
||||||
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
|
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
|
||||||
}
|
}
|
||||||
@@ -121,7 +135,11 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
|
|||||||
}
|
}
|
||||||
context := ValidationContext{
|
context := ValidationContext{
|
||||||
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
|
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
|
||||||
PublishedKnowledgeCardIDs: map[uint64]bool{1: true},
|
KnowledgeItems: []model.KnowledgeItem{
|
||||||
|
{Base: model.Base{ID: 1}, ItemKey: "safety", Type: "guidance", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 2}, ItemKey: "safety_copy", Type: "copy", Status: "active"},
|
||||||
|
},
|
||||||
|
KnowledgeRelations: []model.KnowledgeRelation{{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "related_copy"}},
|
||||||
}
|
}
|
||||||
return nodes, edges, context
|
return nodes, edges, context
|
||||||
}
|
}
|
||||||
|
|||||||
28
main.go
28
main.go
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/httpserver"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/httpserver"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/migration"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/migration"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/multitable"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ var migrationFiles embed.FS
|
|||||||
//go:embed web/dist/*
|
//go:embed web/dist/*
|
||||||
var frontendFiles embed.FS
|
var frontendFiles embed.FS
|
||||||
|
|
||||||
|
//go:embed sdk/dist/*
|
||||||
|
var sdkFiles embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
environment := flag.String("env", "", "runtime environment: development, test, prod")
|
environment := flag.String("env", "", "runtime environment: development, test, prod")
|
||||||
configDir := flag.String("config-dir", "configs", "configuration directory")
|
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||||
@@ -55,13 +59,29 @@ func main() {
|
|||||||
if err := auth.Seed(db, cfg.Seed); err != nil {
|
if err := auth.Seed(db, cfg.Seed); err != nil {
|
||||||
log.Fatal("seed administrator", zap.Error(err))
|
log.Fatal("seed administrator", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
workerDone := make(chan struct{})
|
||||||
|
workerCtx, stopWorker := context.WithCancel(context.Background())
|
||||||
|
if cfg.MultiTable.Enabled {
|
||||||
|
worker := multitable.NewWorker(db, cfg.MultiTable, log)
|
||||||
|
go func() {
|
||||||
|
defer close(workerDone)
|
||||||
|
log.Info("multitable synchronization started", zap.String("worker", worker.String()))
|
||||||
|
worker.Run(workerCtx)
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(workerDone)
|
||||||
|
}
|
||||||
|
|
||||||
frontend, err := fs.Sub(frontendFiles, "web/dist")
|
frontend, err := fs.Sub(frontendFiles, "web/dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("load embedded frontend", zap.Error(err))
|
log.Fatal("load embedded frontend", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
sdk, err := fs.Sub(sdkFiles, "sdk/dist")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("load embedded sdk", zap.Error(err))
|
||||||
|
}
|
||||||
authService := auth.NewService(db, cfg.Auth)
|
authService := auth.NewService(db, cfg.Auth)
|
||||||
handler := httpserver.New(db, authService, frontend, log, cfg.App.Env)
|
handler := httpserver.New(db, authService, frontend, sdk, log, cfg.App.Env)
|
||||||
server := &http.Server{Addr: cfg.Server.Address(), Handler: handler, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout}
|
server := &http.Server{Addr: cfg.Server.Address(), Handler: handler, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -74,10 +94,16 @@ func main() {
|
|||||||
stop := make(chan os.Signal, 1)
|
stop := make(chan os.Signal, 1)
|
||||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-stop
|
<-stop
|
||||||
|
stopWorker()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
log.Error("graceful shutdown failed", zap.Error(err))
|
log.Error("graceful shutdown failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
|
case <-workerDone:
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Warn("multitable worker did not stop before shutdown timeout")
|
||||||
|
}
|
||||||
log.Info("server stopped")
|
log.Info("server stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
1
migrations/000003_multitable_outbox.down.sql
Normal file
1
migrations/000003_multitable_outbox.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS multitable_outbox;
|
||||||
23
migrations/000003_multitable_outbox.up.sql
Normal file
23
migrations/000003_multitable_outbox.up.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE multitable_outbox (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
audit_log_id BIGINT UNSIGNED NULL,
|
||||||
|
resource VARCHAR(64) NOT NULL,
|
||||||
|
resource_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
dedupe_key VARCHAR(191) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
last_error TEXT NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_multitable_outbox_audit (audit_log_id),
|
||||||
|
UNIQUE KEY uk_multitable_outbox_dedupe (dedupe_key),
|
||||||
|
KEY idx_multitable_outbox_pending (status, available_at),
|
||||||
|
KEY idx_multitable_outbox_resource (tenant_id, resource, resource_id),
|
||||||
|
CONSTRAINT fk_multitable_outbox_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_multitable_outbox_audit FOREIGN KEY (audit_log_id) REFERENCES audit_logs(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
1
migrations/000004_remove_review_workflow.down.sql
Normal file
1
migrations/000004_remove_review_workflow.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-- The review workflow is intentionally not restored by rollback.
|
||||||
15
migrations/000004_remove_review_workflow.up.sql
Normal file
15
migrations/000004_remove_review_workflow.up.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
UPDATE sop_versions
|
||||||
|
SET status = 'draft', reviewed_by = NULL
|
||||||
|
WHERE status = 'reviewing';
|
||||||
|
|
||||||
|
UPDATE sops
|
||||||
|
SET status = CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sop_versions
|
||||||
|
WHERE sop_versions.sop_id = sops.id
|
||||||
|
AND sop_versions.status = 'published'
|
||||||
|
) THEN 'published'
|
||||||
|
ELSE 'draft'
|
||||||
|
END
|
||||||
|
WHERE status = 'reviewing';
|
||||||
4
migrations/000005_generic_scenario_runtime.down.sql
Normal file
4
migrations/000005_generic_scenario_runtime.down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE sop_runs DROP INDEX uk_sop_runs_external_ref;
|
||||||
|
ALTER TABLE sop_runs DROP COLUMN outputs, DROP COLUMN derived, DROP COLUMN input, DROP COLUMN external_ref;
|
||||||
|
ALTER TABLE scenario_fields DROP COLUMN source_path;
|
||||||
|
ALTER TABLE scenarios DROP COLUMN output_schema, DROP COLUMN input_schema;
|
||||||
20
migrations/000005_generic_scenario_runtime.up.sql
Normal file
20
migrations/000005_generic_scenario_runtime.up.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE scenarios
|
||||||
|
ADD COLUMN input_schema JSON NULL AFTER created_by,
|
||||||
|
ADD COLUMN output_schema JSON NULL AFTER input_schema;
|
||||||
|
|
||||||
|
UPDATE scenarios SET input_schema = JSON_OBJECT('fields', JSON_ARRAY()), output_schema = JSON_OBJECT('fields', JSON_ARRAY());
|
||||||
|
ALTER TABLE scenarios MODIFY input_schema JSON NOT NULL, MODIFY output_schema JSON NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE scenario_fields
|
||||||
|
ADD COLUMN source_path VARCHAR(255) NOT NULL DEFAULT '' AFTER field_name;
|
||||||
|
|
||||||
|
ALTER TABLE sop_runs
|
||||||
|
ADD COLUMN external_ref VARCHAR(191) NULL AFTER operator_id,
|
||||||
|
ADD COLUMN input JSON NULL AFTER answers,
|
||||||
|
ADD COLUMN derived JSON NULL AFTER input,
|
||||||
|
ADD COLUMN outputs JSON NULL AFTER derived;
|
||||||
|
|
||||||
|
UPDATE sop_runs SET input = answers, derived = JSON_OBJECT(), outputs = JSON_ARRAY();
|
||||||
|
ALTER TABLE sop_runs MODIFY input JSON NOT NULL, MODIFY derived JSON NOT NULL, MODIFY outputs JSON NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE sop_runs ADD UNIQUE KEY uk_sop_runs_external_ref (tenant_id, sop_id, external_ref);
|
||||||
2
migrations/000006_knowledge_graph.down.sql
Normal file
2
migrations/000006_knowledge_graph.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS knowledge_relations;
|
||||||
|
DROP TABLE IF EXISTS knowledge_items;
|
||||||
38
migrations/000006_knowledge_graph.up.sql
Normal file
38
migrations/000006_knowledge_graph.up.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE knowledge_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
scenario_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
item_key VARCHAR(64) NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
type VARCHAR(64) NOT NULL,
|
||||||
|
content JSON NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_knowledge_items_key (scenario_id, item_key),
|
||||||
|
KEY idx_knowledge_items_type (tenant_id, scenario_id, type, status),
|
||||||
|
CONSTRAINT fk_knowledge_items_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_knowledge_items_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE knowledge_relations (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
scenario_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
from_knowledge_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
relation_type VARCHAR(64) NOT NULL,
|
||||||
|
to_knowledge_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
`condition` JSON NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_knowledge_relation (scenario_id, from_knowledge_id, relation_type, to_knowledge_id),
|
||||||
|
KEY idx_knowledge_relation_from (tenant_id, scenario_id, from_knowledge_id, relation_type),
|
||||||
|
CONSTRAINT fk_knowledge_relations_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_knowledge_relations_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_knowledge_relations_from FOREIGN KEY (from_knowledge_id) REFERENCES knowledge_items(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_knowledge_relations_to FOREIGN KEY (to_knowledge_id) REFERENCES knowledge_items(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
3
migrations/000007_public_sdk_access.down.sql
Normal file
3
migrations/000007_public_sdk_access.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS public_run_sessions;
|
||||||
|
ALTER TABLE scenarios DROP INDEX uk_scenarios_public_key, DROP INDEX uk_scenarios_scenario_key;
|
||||||
|
ALTER TABLE scenarios DROP COLUMN allowed_origins, DROP COLUMN public_key, DROP COLUMN scenario_key;
|
||||||
31
migrations/000007_public_sdk_access.up.sql
Normal file
31
migrations/000007_public_sdk_access.up.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
ALTER TABLE scenarios
|
||||||
|
ADD COLUMN scenario_key VARCHAR(128) NULL AFTER tenant_id,
|
||||||
|
ADD COLUMN public_key VARCHAR(128) NULL AFTER scenario_key,
|
||||||
|
ADD COLUMN allowed_origins JSON NULL AFTER public_key;
|
||||||
|
|
||||||
|
UPDATE scenarios
|
||||||
|
SET scenario_key = CONCAT('scenario-', id),
|
||||||
|
public_key = CONCAT('pk_', REPLACE(UUID(), '-', '')),
|
||||||
|
allowed_origins = JSON_ARRAY();
|
||||||
|
|
||||||
|
ALTER TABLE scenarios
|
||||||
|
MODIFY scenario_key VARCHAR(128) NOT NULL,
|
||||||
|
MODIFY public_key VARCHAR(128) NOT NULL,
|
||||||
|
MODIFY allowed_origins JSON NOT NULL,
|
||||||
|
ADD UNIQUE KEY uk_scenarios_scenario_key (scenario_key),
|
||||||
|
ADD UNIQUE KEY uk_scenarios_public_key (public_key);
|
||||||
|
|
||||||
|
CREATE TABLE public_run_sessions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
run_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
token_hash VARCHAR(64) NOT NULL,
|
||||||
|
expires_at DATETIME(3) NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_public_run_sessions_token (token_hash),
|
||||||
|
KEY idx_public_run_sessions_run (tenant_id, run_id),
|
||||||
|
CONSTRAINT fk_public_sessions_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_public_sessions_run FOREIGN KEY (run_id) REFERENCES sop_runs(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
1
migrations/000008_scenario_rules.down.sql
Normal file
1
migrations/000008_scenario_rules.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS scenario_rules;
|
||||||
18
migrations/000008_scenario_rules.up.sql
Normal file
18
migrations/000008_scenario_rules.up.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE scenario_rules (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
scenario_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
rule_key VARCHAR(64) NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
`condition` JSON NOT NULL,
|
||||||
|
actions JSON NOT NULL,
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_scenario_rules_key (scenario_id, rule_key),
|
||||||
|
KEY idx_scenario_rules_active (tenant_id, scenario_id, status, priority),
|
||||||
|
CONSTRAINT fk_scenario_rules_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_scenario_rules_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
1
migrations/000009_run_knowledge_snapshot.down.sql
Normal file
1
migrations/000009_run_knowledge_snapshot.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sop_runs DROP COLUMN knowledge_snapshot;
|
||||||
3
migrations/000009_run_knowledge_snapshot.up.sql
Normal file
3
migrations/000009_run_knowledge_snapshot.up.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE sop_runs ADD COLUMN knowledge_snapshot JSON NULL AFTER outputs;
|
||||||
|
UPDATE sop_runs SET knowledge_snapshot = JSON_OBJECT('items', JSON_ARRAY(), 'relations', JSON_ARRAY());
|
||||||
|
ALTER TABLE sop_runs MODIFY knowledge_snapshot JSON NOT NULL;
|
||||||
2
migrations/000010_scenario_final_result.down.sql
Normal file
2
migrations/000010_scenario_final_result.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE sop_runs DROP COLUMN final_result;
|
||||||
|
ALTER TABLE scenarios DROP COLUMN result_schema;
|
||||||
8
migrations/000010_scenario_final_result.up.sql
Normal file
8
migrations/000010_scenario_final_result.up.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE scenarios
|
||||||
|
ADD COLUMN result_schema JSON NULL AFTER output_schema;
|
||||||
|
|
||||||
|
UPDATE scenarios SET result_schema = JSON_OBJECT('fields', JSON_ARRAY());
|
||||||
|
ALTER TABLE scenarios MODIFY result_schema JSON NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE sop_runs
|
||||||
|
ADD COLUMN final_result JSON NULL AFTER result;
|
||||||
39
scripts/backfill-multitable/main.go
Normal file
39
scripts/backfill-multitable/main.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/multitable"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||||
|
flag.Parse()
|
||||||
|
cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "load configuration: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log, err := logger.New(cfg.App.Env, cfg.App.Name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "create logger: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer log.Sync()
|
||||||
|
db, err := database.Open(cfg.Database, log)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "connect database: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
count, err := multitable.EnqueueBackfill(db)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "enqueue backfill: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("queued %d multitable projection events\n", count)
|
||||||
|
}
|
||||||
320
scripts/seed-pet-doctor/knowledge_import.go
Normal file
320
scripts/seed-pet-doctor/knowledge_import.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const petKnowledgeSource = "症状-疾病-方案.xlsx"
|
||||||
|
const petScriptSource = "症状-疾病-话术.json"
|
||||||
|
|
||||||
|
func resetPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||||
|
var ids []uint64
|
||||||
|
if err := tx.Model(&model.KnowledgeItem{}).Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Pluck("id", &ids).Error; err != nil {
|
||||||
|
return fmt.Errorf("list old pet knowledge: %w", err)
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND (from_knowledge_id IN ? OR to_knowledge_id IN ?)", tenantID, scenarioID, ids, ids).Delete(&model.KnowledgeRelation{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("delete old pet knowledge relations: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND id IN ?", tenantID, scenarioID, ids).Delete(&model.KnowledgeItem{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("delete old pet knowledge items: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type petScriptDisease struct {
|
||||||
|
Plans []struct {
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Products []struct {
|
||||||
|
SKUCode string `json:"sku_code"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
} `json:"products"`
|
||||||
|
} `json:"plans"`
|
||||||
|
Combinations []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
Products []struct {
|
||||||
|
SKUCode string `json:"sku_code"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
} `json:"products"`
|
||||||
|
Script string `json:"script"`
|
||||||
|
} `json:"combinations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func importPetScripts(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
|
||||||
|
raw, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read pet scripts %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
var source map[string]map[string]petScriptDisease
|
||||||
|
if err := json.Unmarshal(raw, &source); err != nil {
|
||||||
|
return fmt.Errorf("parse pet scripts %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
for symptom, diseases := range source {
|
||||||
|
for disease, definition := range diseases {
|
||||||
|
diseaseKey := petKnowledgeKey("disease", disease)
|
||||||
|
if err := upsertPetScriptItems(tx, tenantID, scenarioID, symptom, disease, diseaseKey, definition); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertPetScriptItems(tx *gorm.DB, tenantID, scenarioID uint64, symptom, disease, diseaseKey string, definition petScriptDisease) error {
|
||||||
|
symptomKey := petKnowledgeKey("symptom", symptom)
|
||||||
|
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, symptomKey, symptom, "symptom", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, diseaseKey, disease, "disease", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := upsertPetRelation(tx, scenarioID, symptomKey, "possible_disease", diseaseKey, 3000); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for index, combination := range definition.Combinations {
|
||||||
|
copyName := fmt.Sprintf("%s-%s话术", disease, combination.Name)
|
||||||
|
copyKey := petKnowledgeKey("copy", "json_"+symptom+"_"+disease+"_"+combination.Name)
|
||||||
|
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "template": strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"), "combination": combination.Name, "weight": combination.Weight})
|
||||||
|
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, copyKey, copyName, "copy", content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_copy", copyKey, 3000+index); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for productIndex, product := range combination.Products {
|
||||||
|
if strings.TrimSpace(product.SKUCode) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
productKey := petKnowledgeKey("product", product.SKUCode)
|
||||||
|
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
|
||||||
|
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3400+index*100+productIndex); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, plan := range definition.Plans {
|
||||||
|
if strings.TrimSpace(plan.Plan) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
planKey := petKnowledgeKey("plan", plan.Plan)
|
||||||
|
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, planKey, plan.Plan, "plan", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_plan", planKey, 3200+index); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for productIndex, product := range plan.Products {
|
||||||
|
if strings.TrimSpace(product.SKUCode) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
productKey := petKnowledgeKey("product", product.SKUCode)
|
||||||
|
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
|
||||||
|
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3600+index*100+productIndex); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertPetKnowledgeItem(tx *gorm.DB, tenantID, scenarioID uint64, key, name, typeName string, content []byte) error {
|
||||||
|
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: name, Type: typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 3000}
|
||||||
|
return tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertPetRelation(tx *gorm.DB, scenarioID uint64, fromKey, relation, toKey string, order int) error {
|
||||||
|
var from, to model.KnowledgeItem
|
||||||
|
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, fromKey).First(&from).Error; err != nil {
|
||||||
|
return fmt.Errorf("find script relation source %s: %w", fromKey, err)
|
||||||
|
}
|
||||||
|
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, toKey).First(&to).Error; err != nil {
|
||||||
|
return fmt.Errorf("find script relation target %s: %w", toKey, err)
|
||||||
|
}
|
||||||
|
row := model.KnowledgeRelation{TenantID: from.TenantID, ScenarioID: scenarioID, FromKnowledgeID: from.ID, RelationType: relation, ToKnowledgeID: to.ID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
|
||||||
|
return tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, from.ID, relation, to.ID).Assign(row).FirstOrCreate(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
type petKnowledgeRow struct {
|
||||||
|
Symptom string
|
||||||
|
Disease string
|
||||||
|
Plan string
|
||||||
|
SKUCode string
|
||||||
|
ProductName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func importPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
|
||||||
|
rows, err := readPetKnowledge(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return upsertPetKnowledge(tx, tenantID, scenarioID, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPetKnowledge(filename string) ([]petKnowledgeRow, error) {
|
||||||
|
book, err := excelize.OpenFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
defer book.Close()
|
||||||
|
sheets := book.GetSheetList()
|
||||||
|
if len(sheets) == 0 {
|
||||||
|
return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename)
|
||||||
|
}
|
||||||
|
iterator, err := book.Rows(sheets[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read pet knowledge worksheet: %w", err)
|
||||||
|
}
|
||||||
|
defer iterator.Close()
|
||||||
|
|
||||||
|
columns := map[string]int{}
|
||||||
|
result := make([]petKnowledgeRow, 0)
|
||||||
|
rowNumber := 0
|
||||||
|
for iterator.Next() {
|
||||||
|
rowNumber++
|
||||||
|
values, err := iterator.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err)
|
||||||
|
}
|
||||||
|
if rowNumber == 1 {
|
||||||
|
for index, value := range values {
|
||||||
|
columns[strings.TrimSpace(value)] = index
|
||||||
|
}
|
||||||
|
for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} {
|
||||||
|
if _, ok := columns[required]; !ok {
|
||||||
|
return nil, fmt.Errorf("pet knowledge is missing column %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := petKnowledgeRow{
|
||||||
|
Symptom: excelValue(values, columns["症状"]),
|
||||||
|
Disease: excelValue(values, columns["疾病"]),
|
||||||
|
Plan: excelValue(values, columns["方案"]),
|
||||||
|
SKUCode: excelValue(values, columns["SKU_CODE"]),
|
||||||
|
ProductName: excelValue(values, columns["商品标题"]),
|
||||||
|
}
|
||||||
|
if row.Symptom == "" && row.Disease == "" && row.Plan == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if row.Symptom == "" || row.Disease == "" {
|
||||||
|
return nil, fmt.Errorf("pet knowledge row %d must contain 场景 and 分型", rowNumber)
|
||||||
|
}
|
||||||
|
result = append(result, row)
|
||||||
|
}
|
||||||
|
if err := iterator.Error(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate pet knowledge: %w", err)
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil, fmt.Errorf("pet knowledge contains no data")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func excelValue(values []string, index int) string {
|
||||||
|
if index >= len(values) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(values[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, rows []petKnowledgeRow) error {
|
||||||
|
type itemDefinition struct {
|
||||||
|
name, typeName string
|
||||||
|
content map[string]interface{}
|
||||||
|
}
|
||||||
|
items := map[string]itemDefinition{}
|
||||||
|
type relationDefinition struct{ from, relation, to string }
|
||||||
|
relations := map[relationDefinition]bool{}
|
||||||
|
for _, row := range rows {
|
||||||
|
symptomKey := petKnowledgeKey("symptom", row.Symptom)
|
||||||
|
diseaseKey := petKnowledgeKey("disease", row.Disease)
|
||||||
|
items[symptomKey] = itemDefinition{name: row.Symptom, typeName: "symptom"}
|
||||||
|
items[diseaseKey] = itemDefinition{name: row.Disease, typeName: "disease"}
|
||||||
|
relations[relationDefinition{symptomKey, "possible_disease", diseaseKey}] = true
|
||||||
|
for index, template := range []string{
|
||||||
|
"结合客户描述,目前更符合“%s”这一分型。建议先向客户说明判断依据,再介绍对应方案。",
|
||||||
|
"针对“%s”,这边建议按下面的方案进行护理和商品搭配;如症状持续或加重,应及时就医。",
|
||||||
|
} {
|
||||||
|
copyName := fmt.Sprintf("%s推荐话术%d", row.Disease, index+1)
|
||||||
|
copyKey := petKnowledgeKey("copy", copyName)
|
||||||
|
items[copyKey] = itemDefinition{name: copyName, typeName: "copy", content: map[string]interface{}{"template": fmt.Sprintf(template, row.Disease)}}
|
||||||
|
relations[relationDefinition{diseaseKey, "recommended_copy", copyKey}] = true
|
||||||
|
}
|
||||||
|
if row.Plan != "" {
|
||||||
|
planKey := petKnowledgeKey("plan", row.Plan)
|
||||||
|
items[planKey] = itemDefinition{name: row.Plan, typeName: "plan"}
|
||||||
|
relations[relationDefinition{diseaseKey, "recommended_plan", planKey}] = true
|
||||||
|
// The current SOP view expands one relation level from a symptom.
|
||||||
|
relations[relationDefinition{symptomKey, "recommended_plan", planKey}] = true
|
||||||
|
}
|
||||||
|
if row.SKUCode != "" {
|
||||||
|
productName := row.ProductName
|
||||||
|
if productName == "" {
|
||||||
|
productName = row.SKUCode
|
||||||
|
}
|
||||||
|
productKey := petKnowledgeKey("product", row.SKUCode)
|
||||||
|
items[productKey] = itemDefinition{name: productName, typeName: "product", content: map[string]interface{}{"sku_code": row.SKUCode, "product_name": productName}}
|
||||||
|
relations[relationDefinition{diseaseKey, "recommended_product", productKey}] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(items))
|
||||||
|
for key := range items {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
ids := make(map[string]uint64, len(keys))
|
||||||
|
for order, key := range keys {
|
||||||
|
definition := items[key]
|
||||||
|
payload := map[string]interface{}{"source": petKnowledgeSource}
|
||||||
|
for name, value := range definition.content {
|
||||||
|
payload[name] = value
|
||||||
|
}
|
||||||
|
content, _ := json.Marshal(payload)
|
||||||
|
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 1000 + order}
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||||
|
return fmt.Errorf("upsert imported knowledge item %s: %w", key, err)
|
||||||
|
}
|
||||||
|
ids[key] = row.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
relationList := make([]relationDefinition, 0, len(relations))
|
||||||
|
for relation := range relations {
|
||||||
|
relationList = append(relationList, relation)
|
||||||
|
}
|
||||||
|
sort.Slice(relationList, func(i, j int) bool {
|
||||||
|
left, right := relationList[i], relationList[j]
|
||||||
|
return left.from+left.relation+left.to < right.from+right.relation+right.to
|
||||||
|
})
|
||||||
|
for order, definition := range relationList {
|
||||||
|
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[definition.from], RelationType: definition.relation, ToKnowledgeID: ids[definition.to], Condition: datatypes.JSON([]byte(`{}`)), SortOrder: 1000 + order}
|
||||||
|
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, row.FromKnowledgeID, row.RelationType, row.ToKnowledgeID).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||||
|
return fmt.Errorf("upsert imported knowledge relation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func petKnowledgeKey(typeName, name string) string {
|
||||||
|
digest := sha256.Sum256([]byte(typeName + "\x00" + strings.TrimSpace(name)))
|
||||||
|
return "xlsx_" + typeName + "_" + hex.EncodeToString(digest[:6])
|
||||||
|
}
|
||||||
@@ -5,14 +5,12 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"path/filepath"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
sopservice "git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,6 +19,7 @@ import (
|
|||||||
type fieldDefinition struct {
|
type fieldDefinition struct {
|
||||||
Key string
|
Key string
|
||||||
Name string
|
Name string
|
||||||
|
SourcePath string
|
||||||
Type string
|
Type string
|
||||||
Required bool
|
Required bool
|
||||||
Options []string
|
Options []string
|
||||||
@@ -45,6 +44,7 @@ type edgeDefinition struct {
|
|||||||
func main() {
|
func main() {
|
||||||
configDir := flag.String("config-dir", "configs", "configuration directory")
|
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||||
environment := flag.String("env", "", "runtime environment")
|
environment := flag.String("env", "", "runtime environment")
|
||||||
|
scriptFile := flag.String("script-file", filepath.Join("..", "docs", "症状-疾病-话术.json"), "pet script knowledge JSON file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
|
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
|
||||||
@@ -63,13 +63,13 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("connect database", zap.Error(err))
|
log.Fatal("connect database", zap.Error(err))
|
||||||
}
|
}
|
||||||
if err := seed(db); err != nil {
|
if err := seed(db, *scriptFile); err != nil {
|
||||||
log.Fatal("seed pet doctor scenario", zap.Error(err))
|
log.Fatal("seed pet doctor scenario", zap.Error(err))
|
||||||
}
|
}
|
||||||
log.Info("pet doctor scenario is ready")
|
log.Info("pet doctor scenario is ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
func seed(db *gorm.DB) error {
|
func seed(db *gorm.DB, scriptFile string) error {
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
tenant, user, err := seedOwner(tx)
|
tenant, user, err := seedOwner(tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,11 +82,19 @@ func seed(db *gorm.DB) error {
|
|||||||
if err := seedFields(tx, tenant.ID, scenario.ID); err != nil {
|
if err := seedFields(tx, tenant.ID, scenario.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
knowledgeIDs, err := seedKnowledgeCards(tx, tenant.ID, user.ID, scenario.ID)
|
if err := seedInputSchema(tx, tenant.ID, scenario.ID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID, knowledgeIDs); err != nil {
|
if err := seedRulesAndOutput(tx, tenant.ID, scenario.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := resetPetKnowledge(tx, tenant.ID, scenario.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := importPetScripts(tx, tenant.ID, scenario.ID, scriptFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -127,7 +135,9 @@ func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error)
|
|||||||
|
|
||||||
func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||||
definitions := petFieldDefinitions()
|
definitions := petFieldDefinitions()
|
||||||
|
keys := make([]string, 0, len(definitions))
|
||||||
for index, definition := range definitions {
|
for index, definition := range definitions {
|
||||||
|
keys = append(keys, definition.Key)
|
||||||
options, _ := json.Marshal(definition.Options)
|
options, _ := json.Marshal(definition.Options)
|
||||||
if definition.Options == nil {
|
if definition.Options == nil {
|
||||||
options = []byte(`[]`)
|
options = []byte(`[]`)
|
||||||
@@ -137,150 +147,142 @@ func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
|||||||
validation = []byte(`{}`)
|
validation = []byte(`{}`)
|
||||||
}
|
}
|
||||||
var field model.ScenarioField
|
var field model.ScenarioField
|
||||||
err := tx.Where("scenario_id = ? AND field_key = ?", scenarioID, definition.Key).Assign(model.ScenarioField{
|
attributes := map[string]interface{}{
|
||||||
TenantID: tenantID, ScenarioID: scenarioID, FieldName: definition.Name, FieldType: definition.Type,
|
"tenant_id": tenantID, "scenario_id": scenarioID, "field_name": definition.Name, "source_path": definition.SourcePath,
|
||||||
Required: definition.Required, Options: datatypes.JSON(options), Validation: datatypes.JSON(validation), SortOrder: index,
|
"field_type": definition.Type, "required": definition.Required, "options": datatypes.JSON(options),
|
||||||
}).FirstOrCreate(&field, model.ScenarioField{FieldKey: definition.Key}).Error
|
"validation": datatypes.JSON(validation), "sort_order": index,
|
||||||
|
}
|
||||||
|
err := tx.Where("scenario_id = ? AND field_key = ?", scenarioID, definition.Key).Assign(attributes).FirstOrCreate(&field, model.ScenarioField{FieldKey: definition.Key}).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("seed field %s: %w", definition.Key, err)
|
return fmt.Errorf("seed field %s: %w", definition.Key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return tx.Where("tenant_id = ? AND scenario_id = ? AND field_key NOT IN ?", tenantID, scenarioID, keys).Delete(&model.ScenarioField{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedInputSchema(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||||
|
fields := make([]model.ScenarioField, 0)
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order,id").Find(&fields).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := make([]map[string]interface{}, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
items = append(items, map[string]interface{}{"key": field.FieldKey, "name": field.FieldName, "type": field.FieldType, "source_path": field.SourcePath, "required": field.Required, "options": json.RawMessage(field.Options), "validation": json.RawMessage(field.Validation)})
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]interface{}{"fields": items})
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Update("input_schema", datatypes.JSON(raw)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func petFieldDefinitions() []fieldDefinition {
|
||||||
|
return []fieldDefinition{
|
||||||
|
{Key: "order_id", Name: "订单号", Type: "text", SourcePath: "order.id"},
|
||||||
|
{Key: "customer_name", Name: "客户称呼", Type: "text", SourcePath: "customer.name"},
|
||||||
|
{Key: "product_ids", Name: "订单商品 ID", Type: "array", SourcePath: "order.items[*].product_id"},
|
||||||
|
{Key: "product_names", Name: "订单商品名称", Type: "array", SourcePath: "order.items[*].product_name"},
|
||||||
|
{Key: "product_images", Name: "订单商品图片", Type: "array", SourcePath: "order.items[*].image_url"},
|
||||||
|
{Key: "input_symptom_tags", Name: "商品症状标签", Type: "array", SourcePath: "order.items[*].symptom_tags[*]"},
|
||||||
|
{Key: "pet_name", Name: "宠物名称", Type: "text", SourcePath: "pet.name", Validation: map[string]interface{}{"max_length": 50}},
|
||||||
|
{Key: "pet_type", Name: "宠物种类", Type: "select", SourcePath: "pet.species", Options: []string{"犬", "猫", "其他"}},
|
||||||
|
{Key: "pet_age", Name: "宠物年龄", Type: "text", Validation: map[string]interface{}{"max_length": 30}},
|
||||||
|
{Key: "pet_weight", Name: "宠物体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.1, "max": 200}},
|
||||||
|
{Key: "pet_sex", Name: "宠物性别", Type: "select", Options: []string{"公", "母", "未知"}},
|
||||||
|
{Key: "confirmed_symptoms", Name: "客户确认症状", Type: "array", Required: true},
|
||||||
|
{Key: "confirmed_diseases", Name: "确认疾病分型", Type: "array", Required: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedKnowledgeGraph(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||||
|
definitions := map[string]struct {
|
||||||
|
name string
|
||||||
|
typeName string
|
||||||
|
content map[string]interface{}
|
||||||
|
}{
|
||||||
|
"consultation_boundary": {name: "线上问诊边界", typeName: "guidance", content: map[string]interface{}{
|
||||||
|
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
|
||||||
|
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
|
||||||
|
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
|
||||||
|
}},
|
||||||
|
"emergency_guidance": {name: "急症红旗征象", typeName: "guidance", content: map[string]interface{}{
|
||||||
|
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
|
||||||
|
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
|
||||||
|
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
|
||||||
|
}},
|
||||||
|
"medication_safety": {name: "问诊用药安全原则", typeName: "guidance", content: map[string]interface{}{
|
||||||
|
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
|
||||||
|
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
|
||||||
|
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
|
||||||
|
}},
|
||||||
|
"safety_net": {name: "观察与复诊提示", typeName: "guidance", content: map[string]interface{}{
|
||||||
|
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
|
||||||
|
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
|
||||||
|
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
|
||||||
|
}},
|
||||||
|
"soft_stool": {name: "软便", typeName: "symptom", content: map[string]interface{}{"label": "soft_stool"}},
|
||||||
|
"poor_appetite": {name: "食欲下降", typeName: "symptom", content: map[string]interface{}{"label": "poor_appetite"}},
|
||||||
|
"ask_stool": {name: "软便追问话术", typeName: "copy", content: map[string]interface{}{"template": "{{input.customer_name}}您好,想了解一下{{input.pet_name}}近期排便的频率和形态,是否伴随呕吐、精神变差或便中带血?"}},
|
||||||
|
"recommend_gut": {name: "肠胃护理推荐话术", typeName: "copy", content: map[string]interface{}{"template": "结合{{input.pet_name}}目前的表现,可以先向您介绍肠胃护理方向的产品和日常喂养注意事项,具体用药仍需宠物医生评估。"}},
|
||||||
|
"gi_discomfort": {name: "肠胃不适风险", typeName: "disease", content: map[string]interface{}{"risk_level": "normal", "label": "gi_discomfort"}},
|
||||||
|
"parasite_risk": {name: "寄生虫风险", typeName: "disease", content: map[string]interface{}{"risk_level": "medium", "label": "parasite_risk"}},
|
||||||
|
}
|
||||||
|
index := 0
|
||||||
|
for key, definition := range definitions {
|
||||||
|
content, _ := json.Marshal(definition.content)
|
||||||
|
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: index}
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||||
|
return fmt.Errorf("seed knowledge item %s: %w", key, err)
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
relations := []struct{ from, relation, to string }{
|
||||||
|
{"soft_stool", "recommended_copy", "ask_stool"}, {"soft_stool", "recommended_copy", "recommend_gut"},
|
||||||
|
{"soft_stool", "possible_disease", "gi_discomfort"}, {"soft_stool", "possible_disease", "parasite_risk"},
|
||||||
|
{"poor_appetite", "recommended_copy", "recommend_gut"}, {"poor_appetite", "possible_disease", "gi_discomfort"},
|
||||||
|
}
|
||||||
|
ids := map[string]uint64{}
|
||||||
|
var items []model.KnowledgeItem
|
||||||
|
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Find(&items).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
ids[item.ItemKey] = item.ID
|
||||||
|
}
|
||||||
|
for order, relation := range relations {
|
||||||
|
fromID, fromOK := ids[relation.from]
|
||||||
|
toID, toOK := ids[relation.to]
|
||||||
|
if !fromOK || !toOK {
|
||||||
|
return fmt.Errorf("knowledge relation references missing item: %s -> %s", relation.from, relation.to)
|
||||||
|
}
|
||||||
|
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: fromID, RelationType: relation.relation, ToKnowledgeID: toID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
|
||||||
|
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, fromID, relation.relation, toID).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||||
|
return fmt.Errorf("seed knowledge relation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func petFieldDefinitions() []fieldDefinition {
|
func seedRulesAndOutput(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||||
return []fieldDefinition{
|
rule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-symptoms", Name: "从商品标签提取症状", Condition: datatypes.JSON([]byte(`{"field":"input.input_symptom_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_symptom_tags"}]`)), Priority: 100, Status: "active"}
|
||||||
{Key: "consultation_channel", Name: "咨询渠道", Type: "select", Required: true, Options: []string{"到店", "电话", "在线图文", "视频"}},
|
if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, rule.RuleKey).Assign(rule).FirstOrCreate(&rule).Error; err != nil {
|
||||||
{Key: "visit_goal", Name: "本次咨询目的", Type: "select", Required: true, Options: []string{"症状咨询", "用药咨询", "复诊随访", "检查报告解读", "其他"}},
|
|
||||||
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 50}},
|
|
||||||
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
|
|
||||||
{Key: "pet_breed", Name: "品种", Type: "text", Validation: map[string]interface{}{"max_length": 50}},
|
|
||||||
{Key: "pet_age", Name: "年龄(岁)", Type: "number", Validation: map[string]interface{}{"min": 0, "max": 50}},
|
|
||||||
{Key: "pet_weight", Name: "体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.01, "max": 200}},
|
|
||||||
{Key: "pet_sex", Name: "性别", Type: "select", Options: []string{"公", "母", "未知"}},
|
|
||||||
{Key: "is_neutered", Name: "是否绝育", Type: "boolean"},
|
|
||||||
{Key: "pregnancy_status", Name: "妊娠或哺乳情况", Type: "select", Options: []string{"不适用", "否", "可能妊娠", "妊娠中", "哺乳中", "不清楚"}},
|
|
||||||
{Key: "symptom", Name: "主要症状", Type: "textarea", Required: true, Validation: map[string]interface{}{"max_length": 1000}},
|
|
||||||
{Key: "symptom_duration", Name: "症状开始时间及持续时长", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 100}},
|
|
||||||
{Key: "symptom_trend", Name: "症状变化趋势", Type: "select", Required: true, Options: []string{"好转", "稳定", "反复", "加重", "不清楚"}},
|
|
||||||
{Key: "prior_visit", Name: "本次问题是否已就诊", Type: "boolean"},
|
|
||||||
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
|
|
||||||
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
|
|
||||||
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
|
|
||||||
{Key: "severe_trauma", Name: "是否遭遇严重外伤或高处坠落", Type: "boolean", Required: true},
|
|
||||||
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
|
|
||||||
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
|
|
||||||
{Key: "repeated_vomiting", Name: "是否持续呕吐或干呕", Type: "boolean", Required: true},
|
|
||||||
{Key: "severe_pain", Name: "是否剧烈疼痛或腹部明显膨大", Type: "boolean", Required: true},
|
|
||||||
{Key: "heatstroke_sign", Name: "是否疑似中暑或体温异常升高", Type: "boolean", Required: true},
|
|
||||||
{Key: "unable_to_stand", Name: "是否虚弱到无法站立", Type: "boolean", Required: true},
|
|
||||||
{Key: "has_emergency_sign", Name: "是否存在其他危及生命的表现", Type: "boolean", Required: true},
|
|
||||||
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
|
|
||||||
{Key: "water_intake", Name: "饮水情况", Type: "select", Options: []string{"正常", "增多", "减少", "完全不喝", "不清楚"}},
|
|
||||||
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
|
|
||||||
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
|
|
||||||
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
|
|
||||||
{Key: "urination_status", Name: "排尿情况", Type: "select", Options: []string{"正常", "增多", "减少", "频繁少量", "带血", "不清楚"}},
|
|
||||||
{Key: "stool_status", Name: "排便情况", Type: "select", Options: []string{"正常", "稀软", "水样", "带血或黑便", "便秘", "不清楚"}},
|
|
||||||
{Key: "measured_temperature", Name: "实测体温(℃)", Type: "number", Validation: map[string]interface{}{"min": 30, "max": 45}},
|
|
||||||
{Key: "chronic_disease_history", Name: "既往疾病及手术史", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
|
|
||||||
{Key: "vaccination_status", Name: "免疫情况", Type: "select", Options: []string{"按期完成", "未完成", "逾期", "不清楚", "不适用"}},
|
|
||||||
{Key: "medication_history", Name: "近期用药、驱虫药和保健品", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
|
|
||||||
{Key: "allergy_history", Name: "已知药物过敏或不良反应史", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
|
|
||||||
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
|
|
||||||
{Key: "medication_request_type", Name: "用药咨询类型", Type: "select", Options: []string{"新症状想用药", "询问现有处方用法", "续方或复购", "漏服或多服", "服药后异常", "其他"}},
|
|
||||||
{Key: "requested_medication", Name: "咨询的药品名称及规格", Type: "text", Validation: map[string]interface{}{"max_length": 200}},
|
|
||||||
{Key: "prescribed_by_vet", Name: "是否由宠物医生开具", Type: "boolean"},
|
|
||||||
{Key: "has_taken_medication", Name: "是否已经使用该药", Type: "boolean"},
|
|
||||||
{Key: "dose_and_time", Name: "已用剂量、次数及最后用药时间", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
|
|
||||||
{Key: "human_medication_exposure", Name: "是否使用或误食人用药", Type: "boolean", Required: true},
|
|
||||||
{Key: "suspected_overdose", Name: "是否可能过量或重复用药", Type: "boolean", Required: true},
|
|
||||||
{Key: "adverse_reaction", Name: "用药后是否出现异常反应", Type: "boolean", Required: true},
|
|
||||||
{Key: "adverse_reaction_detail", Name: "异常反应描述", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[string]uint64, error) {
|
|
||||||
definitions := map[string]map[string]interface{}{
|
|
||||||
"线上问诊边界": {
|
|
||||||
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
|
|
||||||
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
|
|
||||||
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
|
|
||||||
},
|
|
||||||
"急症红旗征象": {
|
|
||||||
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
|
|
||||||
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
|
|
||||||
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
|
|
||||||
},
|
|
||||||
"问诊用药安全原则": {
|
|
||||||
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
|
|
||||||
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
|
|
||||||
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
|
|
||||||
},
|
|
||||||
"观察与复诊提示": {
|
|
||||||
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
|
|
||||||
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
|
|
||||||
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result := make(map[string]uint64, len(definitions))
|
|
||||||
for title, content := range definitions {
|
|
||||||
var card model.KnowledgeCard
|
|
||||||
err := tx.Where("tenant_id = ? AND scenario_id = ? AND title = ?", tenantID, scenarioID, title).FirstOrCreate(&card, model.KnowledgeCard{
|
|
||||||
TenantID: tenantID, ScenarioID: scenarioID, Title: title, Status: "published", CreatedBy: userID,
|
|
||||||
}).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("seed knowledge card %s: %w", title, err)
|
|
||||||
}
|
|
||||||
if err := tx.Model(&card).Update("status", "published").Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
contentJSON, _ := json.Marshal(content)
|
|
||||||
if err := publishSeedKnowledgeVersion(tx, tenantID, card.ID, contentJSON); err != nil {
|
|
||||||
return nil, fmt.Errorf("seed knowledge card version %s: %w", title, err)
|
|
||||||
}
|
|
||||||
result[title] = card.ID
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func publishSeedKnowledgeVersion(tx *gorm.DB, tenantID, cardID uint64, contentJSON []byte) error {
|
|
||||||
var latest model.KnowledgeCardVersion
|
|
||||||
err := tx.Where("knowledge_card_id = ? AND tenant_id = ?", cardID, tenantID).Order("version DESC").First(&latest).Error
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return tx.Create(&model.KnowledgeCardVersion{
|
|
||||||
TenantID: tenantID, KnowledgeCardID: cardID, Version: 1,
|
|
||||||
Content: datatypes.JSON(contentJSON), Status: "published",
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if sameJSON(latest.Content, contentJSON) {
|
out := map[string]interface{}{"fields": []map[string]interface{}{
|
||||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND id <> ? AND status = ?", cardID, tenantID, latest.ID, "published").Update("status", "superseded").Error; err != nil {
|
{"key": "matched_symptoms", "name": "命中症状", "type": "array", "source": "derived", "source_field": "matched_symptoms"},
|
||||||
return err
|
{"key": "product_names", "name": "订单商品", "type": "array", "source": "input", "source_field": "product_names"},
|
||||||
}
|
}}
|
||||||
return tx.Model(&latest).Update("status", "published").Error
|
result := map[string]interface{}{"fields": []map[string]interface{}{
|
||||||
}
|
{"key": "recommended_products", "name": "成功推荐商品", "type": "array", "items": map[string]interface{}{"type": "object", "fields": []map[string]interface{}{
|
||||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", cardID, tenantID, "published").Update("status", "superseded").Error; err != nil {
|
{"key": "product_id", "name": "商品 ID", "type": "string", "required": true},
|
||||||
return err
|
{"key": "product_name", "name": "商品名称", "type": "string", "required": true},
|
||||||
}
|
{"key": "quantity", "name": "推荐数量", "type": "integer"},
|
||||||
return tx.Create(&model.KnowledgeCardVersion{
|
}}},
|
||||||
TenantID: tenantID, KnowledgeCardID: cardID, Version: latest.Version + 1,
|
{"key": "result_note", "name": "结果备注", "type": "text"},
|
||||||
Content: datatypes.JSON(contentJSON), Status: "published",
|
}}
|
||||||
}).Error
|
outputRaw, _ := json.Marshal(out)
|
||||||
|
resultRaw, _ := json.Marshal(result)
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Updates(map[string]interface{}{"output_schema": datatypes.JSON(outputRaw), "result_schema": datatypes.JSON(resultRaw)}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameJSON(left, right []byte) bool {
|
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||||
var leftValue interface{}
|
|
||||||
var rightValue interface{}
|
|
||||||
if json.Unmarshal(left, &leftValue) != nil || json.Unmarshal(right, &rightValue) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return reflect.DeepEqual(leftValue, rightValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]uint64) error {
|
|
||||||
var sop model.SOP
|
var sop model.SOP
|
||||||
err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{
|
err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{
|
||||||
TenantID: tenantID, ScenarioID: scenarioID, Name: "宠物问诊问药标准 SOP", CreatedBy: userID,
|
TenantID: tenantID, ScenarioID: scenarioID, Name: "宠物问诊问药标准 SOP", CreatedBy: userID,
|
||||||
@@ -289,38 +291,36 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
|||||||
return fmt.Errorf("find or create SOP: %w", err)
|
return fmt.Errorf("find or create SOP: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.Model(&sop).Updates(map[string]interface{}{
|
if err := tx.Model(&sop).Updates(map[string]interface{}{
|
||||||
"description": "标准化采集宠物主诉和病史,通过急症及用药风险双重分流,形成医生评估、转诊和复诊闭环。",
|
"description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
|
||||||
"status": "published",
|
"status": "published",
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var seededPublished int64
|
|
||||||
if err := tx.Table("sop_nodes n").Joins("JOIN sop_versions v ON v.id = n.sop_version_id").Where(
|
|
||||||
"v.sop_id = ? AND n.node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.seed_key')) = ?",
|
|
||||||
sop.ID, "start", "pet-doctor-v5",
|
|
||||||
).Count(&seededPublished).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if seededPublished > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var version model.SOPVersion
|
var version model.SOPVersion
|
||||||
err = tx.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, tenantID, "draft").Order("version DESC").First(&version).Error
|
err = tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Order("version DESC").First(&version).Error
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
var maxVersion int
|
version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: userID}
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ?", sop.ID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: maxVersion + 1, Status: "draft", StartNodeKey: "start", CreatedBy: userID}
|
|
||||||
if err := tx.Create(&version).Error; err != nil {
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var alreadySeeded int64
|
||||||
|
if err := tx.Model(&model.SOPNode{}).Where(
|
||||||
|
"sop_version_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
|
||||||
|
version.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
|
||||||
|
).Count(&alreadySeeded).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if alreadySeeded > 0 {
|
||||||
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&version).Updates(map[string]interface{}{"status": "published", "start_node_key": "start"}).Error
|
||||||
|
}
|
||||||
|
|
||||||
nodes := petNodes(knowledgeIDs)
|
nodes := petNodes()
|
||||||
edges := petEdges()
|
edges := petEdges()
|
||||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -348,91 +348,44 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
|||||||
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
|
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := sopservice.BindKnowledgeVersions(tx, tenantID, version.ID); err != nil {
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
|
||||||
return fmt.Errorf("bind knowledge versions: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ? AND status = ?", sop.ID, version.ID, "published").Update("status", "superseded").Error; err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(&version).Updates(map[string]interface{}{
|
if err := tx.Model(&version).Updates(map[string]interface{}{
|
||||||
"status": "published", "start_node_key": "start", "published_at": &now, "reviewed_by": userID,
|
"status": "published", "start_node_key": "start",
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "version": version.Version})
|
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"})
|
||||||
return tx.Create(&model.AuditLog{
|
return tx.Create(&model.AuditLog{
|
||||||
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
|
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func petNodes(knowledgeIDs map[string]uint64) []nodeDefinition {
|
func petNodes() []nodeDefinition {
|
||||||
return []nodeDefinition{
|
return []nodeDefinition{
|
||||||
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v5"}},
|
{Key: "start", Type: "start", Title: "开始", Content: "确认订单和客户信息,使用开场话术开始沟通。", Config: map[string]interface{}{
|
||||||
{Key: "opening", Type: "message", Title: "说明问诊流程与边界", Content: "您好,我会先记录本次咨询目的、宠物基本信息和症状,优先排查是否需要立即就医,再将完整信息提交给宠物医生评估。线上沟通不能替代医生检查。", Config: map[string]interface{}{}},
|
"seed_key": "pet-doctor-v16-start-presentation-optional-pet",
|
||||||
{Key: "consultation_context", Type: "form", Title: "确认咨询背景", Content: "请确认本次咨询渠道和主要目的,便于医生判断沟通重点。", Config: map[string]interface{}{"field_keys": []string{"consultation_channel", "visit_goal"}}},
|
"presentation": map[string]interface{}{
|
||||||
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、品种、年龄、体重、性别、绝育以及妊娠或哺乳情况;不确定的信息可以留空。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_breed", "pet_age", "pet_weight", "pet_sex", "is_neutered", "pregnancy_status"}}},
|
"summary_field_keys": []string{"order_id", "customer_name", "pet_name", "pet_type"},
|
||||||
{Key: "chief_complaint", Type: "form", Title: "记录主诉与变化", Content: "请让宠物主人按时间顺序描述主要症状、开始时间、变化趋势,以及是否已经就诊。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration", "symptom_trend", "prior_visit"}}},
|
"item_field_keys": []string{"product_images", "product_names", "product_ids"},
|
||||||
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "请逐项询问,不要仅凭主人主动描述判断。任意一项为“是”都应停止常规问诊并优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "severe_trauma", "unable_to_urinate", "toxin_exposure", "repeated_vomiting", "severe_pain", "heatstroke_sign", "unable_to_stand", "has_emergency_sign"}}},
|
"image_field_keys": []string{"product_images"},
|
||||||
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
|
"opening_title": "开场话术",
|
||||||
{Key: "emergency_guidance", Type: "knowledge", Title: "告知急症处置原则", Content: "请按知识卡向宠物主人说明急症风险和立即就医要求。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["急症红旗征象"]}},
|
"opening_template": "您好,{{input.customer_name}}。我是宠物健康顾问,看到您购买了{{input.product_names}},想回访了解一下使用情况,也了解一下宠物目前的身体状况。",
|
||||||
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "请停止常规线上问诊,建议立即前往最近的宠物急诊并提前联系接诊机构。保持宠物安静和通风,除接诊医生明确指导外,不要自行喂药、催吐或强行喂食,并携带可疑药物、毒物包装及既往资料。", Config: map[string]interface{}{"action": "urgent_referral"}},
|
},
|
||||||
{Key: "current_status", Type: "form", Title: "补充当前生命状态", Content: "继续了解食欲、饮水、精神、呕吐、腹泻、排尿、排便和实测体温。未测体温时请留空,不要凭触摸估计。", Config: map[string]interface{}{"field_keys": []string{"appetite", "water_intake", "spirit_status", "vomiting", "diarrhea", "urination_status", "stool_status", "measured_temperature"}}},
|
}},
|
||||||
{Key: "medical_history", Type: "form", Title: "补充既往史", Content: "请记录既往疾病和手术、免疫情况、近期所有药物及保健品,以及已知药物过敏或不良反应。", Config: map[string]interface{}{"field_keys": []string{"chronic_disease_history", "vaccination_status", "medication_history", "allergy_history"}}},
|
{Key: "pet_info", Type: "form", Title: "收集宠物信息", Content: "核对系统已带入的宠物信息,并根据客户回答补充名称、种类、年龄、体重和性别。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}},
|
||||||
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量、疗程、续方、漏服或用药后异常。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
|
{Key: "symptom", Type: "knowledge", Title: "确认宠物症状与疾病", Content: "商品标签是系统推断的候选症状;请结合客户回答确认,并可从场景知识中补充其他伴随症状。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"derived_field": "matched_symptoms", "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "relation_types": []string{"recommended_copy", "possible_disease"}, "relation_labels": map[string]string{"recommended_copy": "沟通话术", "possible_disease": "可能疾病"}}, "knowledge_collection": map[string]interface{}{"selection_title": "确认实际症状和疾病", "selection_hint": "系统推断项优先展示,也可搜索场景知识补充伴随症状", "steps": []map[string]interface{}{{"field_key": "confirmed_symptoms", "name": "客户确认症状", "root": true, "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "required": true, "multiple": true}, {"field_key": "confirmed_diseases", "name": "确认疾病分型", "from_field": "confirmed_symptoms", "relation_type": "possible_disease", "required": true, "multiple": true}}}}},
|
||||||
{Key: "medication_details", Type: "form", Title: "采集用药详情", Content: "请核对药名和规格、咨询类型、是否由宠物医生开具、是否已经使用,以及已用剂量和时间。无法确认时请查看处方或药品包装,不要猜测。", Config: map[string]interface{}{"field_keys": []string{"medication_request_type", "requested_medication", "prescribed_by_vet", "has_taken_medication", "dose_and_time"}}},
|
{Key: "recommend_product", Type: "knowledge", Title: "推荐方案与商品", Content: "根据已确认的疾病分型,向客户讲解推荐话术、建议方案和可使用的商品。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"answer_field": "confirmed_diseases", "knowledge_types": []string{"disease"}, "relation_types": []string{"recommended_copy", "recommended_plan", "recommended_product"}, "relation_labels": map[string]string{"recommended_copy": "推荐话术", "recommended_plan": "建议方案", "recommended_product": "推荐商品"}}}},
|
||||||
{Key: "medication_risk_screen", Type: "form", Title: "筛查用药风险", Content: "请确认是否涉及人用药、可能过量或重复用药,以及用药后异常;如有异常,请记录出现时间和具体表现。", Config: map[string]interface{}{"field_keys": []string{"human_medication_exposure", "suspected_overdose", "adverse_reaction", "adverse_reaction_detail"}}},
|
{Key: "finish", Type: "finish", Title: "结束", Content: "本次话术执行完成,请保存最终推荐结果。", Config: map[string]interface{}{}},
|
||||||
{Key: "medication_risk_check", Type: "condition", Title: "判断是否存在用药急症风险", Content: "系统根据用药风险信息自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
|
|
||||||
{Key: "medication_escalate", Type: "escalate", Title: "立即转医生或急诊评估", Content: "存在误用人用药、可能过量或用药后异常。请立即转宠物医生评估;如出现呼吸困难、意识异常、抽搐、虚脱或症状快速加重,应立即前往急诊。请保留药品包装、处方和实际用药时间记录。", Config: map[string]interface{}{"action": "medication_risk_referral"}},
|
|
||||||
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "请按知识卡说明用药边界,在医生完成评估前不要承诺具体药名、剂量或疗程。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["问诊用药安全原则"]}},
|
|
||||||
{Key: "consultation_boundary", Type: "knowledge", Title: "说明线上问诊边界", Content: "请说明线上沟通的作用和限制,并确认宠物主人理解下一步需要医生评估。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["线上问诊边界"]}},
|
|
||||||
{Key: "assessment", Type: "message", Title: "提交医生评估并明确下一步", Content: "信息已记录。请由宠物医生结合病史、体格检查和必要检验判断后续方案;向宠物主人明确是立即到院、预约就诊、按原处方处理还是居家观察,并记录医生给出的时间要求。", Config: map[string]interface{}{}},
|
|
||||||
{Key: "safety_net", Type: "knowledge", Title: "确认观察和复诊提示", Content: "请按知识卡说明观察指标、复诊时间和立即就医触发条件,并请宠物主人复述确认。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["观察与复诊提示"]}},
|
|
||||||
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已记录,请按宠物医生给出的时间和方式安排检查、复诊或治疗;情况变化时及时重新联系或立即就医。", Config: map[string]interface{}{}},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func petEdges() []edgeDefinition {
|
func petEdges() []edgeDefinition {
|
||||||
emergencyRules := []interface{}{
|
|
||||||
map[string]interface{}{"field": "breathing_difficulty", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "active_bleeding", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "convulsion", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "severe_trauma", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "unable_to_urinate", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "toxin_exposure", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "repeated_vomiting", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "severe_pain", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "heatstroke_sign", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "unable_to_stand", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true},
|
|
||||||
}
|
|
||||||
medicationRiskRules := []interface{}{
|
|
||||||
map[string]interface{}{"field": "human_medication_exposure", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "suspected_overdose", "operator": "equals", "value": true},
|
|
||||||
map[string]interface{}{"field": "adverse_reaction", "operator": "equals", "value": true},
|
|
||||||
}
|
|
||||||
return []edgeDefinition{
|
return []edgeDefinition{
|
||||||
{Source: "start", Target: "opening", Condition: map[string]interface{}{}},
|
{Source: "start", Target: "pet_info", Condition: map[string]interface{}{}},
|
||||||
{Source: "opening", Target: "consultation_context", Condition: map[string]interface{}{}},
|
{Source: "pet_info", Target: "symptom", Condition: map[string]interface{}{}},
|
||||||
{Source: "consultation_context", Target: "basic_info", Condition: map[string]interface{}{}},
|
{Source: "symptom", Target: "recommend_product", Condition: map[string]interface{}{}},
|
||||||
{Source: "basic_info", Target: "chief_complaint", Condition: map[string]interface{}{}},
|
{Source: "recommend_product", Target: "finish", Condition: map[string]interface{}{}},
|
||||||
{Source: "chief_complaint", Target: "emergency_screen", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "emergency_screen", Target: "emergency_check", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "emergency_check", Target: "emergency_guidance", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
|
|
||||||
{Source: "emergency_check", Target: "current_status", Condition: map[string]interface{}{}, Priority: 100},
|
|
||||||
{Source: "emergency_guidance", Target: "emergency_escalate", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "current_status", Target: "medical_history", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "medical_history", Target: "medication_intent", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "medication_intent", Target: "medication_details", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
|
|
||||||
{Source: "medication_intent", Target: "consultation_boundary", Condition: map[string]interface{}{}, Priority: 100},
|
|
||||||
{Source: "medication_details", Target: "medication_risk_screen", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "medication_risk_screen", Target: "medication_risk_check", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "medication_risk_check", Target: "medication_escalate", Condition: map[string]interface{}{"any": medicationRiskRules}, Priority: 0},
|
|
||||||
{Source: "medication_risk_check", Target: "medication_safety", Condition: map[string]interface{}{}, Priority: 100},
|
|
||||||
{Source: "medication_safety", Target: "consultation_boundary", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "consultation_boundary", Target: "assessment", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "assessment", Target: "safety_net", Condition: map[string]interface{}{}},
|
|
||||||
{Source: "safety_net", Target: "finish", Condition: map[string]interface{}{}},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,25 +11,33 @@ import (
|
|||||||
|
|
||||||
func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
|
func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
|
||||||
fields := petFieldModels()
|
fields := petFieldModels()
|
||||||
knowledgeIDs := map[string]uint64{
|
nodes := petNodeModels(petNodes())
|
||||||
"线上问诊边界": 1,
|
|
||||||
"急症红旗征象": 2,
|
|
||||||
"问诊用药安全原则": 3,
|
|
||||||
"观察与复诊提示": 4,
|
|
||||||
}
|
|
||||||
nodes := petNodeModels(petNodes(knowledgeIDs))
|
|
||||||
edges := petEdgeModels(petEdges())
|
edges := petEdgeModels(petEdges())
|
||||||
if len(fields) != 47 || len(nodes) != 21 || len(edges) != 21 {
|
if len(fields) != 13 || len(nodes) != 5 || len(edges) != 4 {
|
||||||
t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges))
|
t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges))
|
||||||
}
|
}
|
||||||
|
|
||||||
problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{
|
problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
PublishedKnowledgeCardIDs: map[uint64]bool{
|
KnowledgeItems: []model.KnowledgeItem{
|
||||||
1: true,
|
{Base: model.Base{ID: 1}, ItemKey: "consultation_boundary", Type: "guidance", Status: "active"},
|
||||||
2: true,
|
{Base: model.Base{ID: 2}, ItemKey: "emergency_guidance", Type: "guidance", Status: "active"},
|
||||||
3: true,
|
{Base: model.Base{ID: 3}, ItemKey: "medication_safety", Type: "guidance", Status: "active"},
|
||||||
4: true,
|
{Base: model.Base{ID: 4}, ItemKey: "safety_net", Type: "guidance", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 5}, ItemKey: "soft_stool", Type: "symptom", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 6}, ItemKey: "poor_appetite", Type: "symptom", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 7}, ItemKey: "ask_stool", Type: "copy", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 8}, ItemKey: "gi_discomfort", Type: "disease", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 9}, ItemKey: "gut_plan", Type: "plan", Status: "active"},
|
||||||
|
{Base: model.Base{ID: 10}, ItemKey: "gut_product", Type: "product", Status: "active"},
|
||||||
|
},
|
||||||
|
KnowledgeRelations: []model.KnowledgeRelation{
|
||||||
|
{FromKnowledgeID: 5, ToKnowledgeID: 7, RelationType: "recommended_copy"},
|
||||||
|
{FromKnowledgeID: 5, ToKnowledgeID: 8, RelationType: "possible_disease"},
|
||||||
|
{FromKnowledgeID: 5, ToKnowledgeID: 9, RelationType: "recommended_plan"},
|
||||||
|
{FromKnowledgeID: 8, ToKnowledgeID: 7, RelationType: "recommended_copy"},
|
||||||
|
{FromKnowledgeID: 8, ToKnowledgeID: 9, RelationType: "recommended_plan"},
|
||||||
|
{FromKnowledgeID: 8, ToKnowledgeID: 10, RelationType: "recommended_product"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
@@ -37,31 +45,27 @@ func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPetDoctorSOPHasBothRiskEscalations(t *testing.T) {
|
func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) {
|
||||||
nodes := petNodes(map[string]uint64{
|
wantNodes := []string{"start", "pet_info", "symptom", "recommend_product", "finish"}
|
||||||
"线上问诊边界": 1,
|
for index, node := range petNodes() {
|
||||||
"急症红旗征象": 2,
|
if node.Key != wantNodes[index] {
|
||||||
"问诊用药安全原则": 3,
|
t.Fatalf("node %d = %s, want %s", index, node.Key, wantNodes[index])
|
||||||
"观察与复诊提示": 4,
|
|
||||||
})
|
|
||||||
escalations := map[string]bool{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
if node.Type == "escalate" {
|
|
||||||
escalations[node.Key] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, key := range []string{"emergency_escalate", "medication_escalate"} {
|
wantEdges := [][2]string{{"start", "pet_info"}, {"pet_info", "symptom"}, {"symptom", "recommend_product"}, {"recommend_product", "finish"}}
|
||||||
if !escalations[key] {
|
for index, edge := range petEdges() {
|
||||||
t.Fatalf("missing risk escalation node %s", key)
|
if edge.Source != wantEdges[index][0] || edge.Target != wantEdges[index][1] {
|
||||||
|
t.Fatalf("edge %d = %s -> %s", index, edge.Source, edge.Target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSameJSONIgnoresObjectOrderAndWhitespace(t *testing.T) {
|
func TestPetKnowledgeKeyIsStableAndTypeScoped(t *testing.T) {
|
||||||
left := []byte(`{"standard_copy":"安全提示","risk_note":"立即就医"}`)
|
if petKnowledgeKey("symptom", "腹泻") != petKnowledgeKey("symptom", " 腹泻 ") {
|
||||||
right := []byte(`{ "risk_note": "立即就医", "standard_copy": "安全提示" }`)
|
t.Fatal("key should ignore surrounding whitespace")
|
||||||
if !sameJSON(left, right) {
|
}
|
||||||
t.Fatal("equivalent JSON objects should match")
|
if petKnowledgeKey("symptom", "腹泻") == petKnowledgeKey("disease", "腹泻") {
|
||||||
|
t.Fatal("key should include knowledge type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +78,7 @@ func petFieldModels() []model.ScenarioField {
|
|||||||
fields = append(fields, model.ScenarioField{
|
fields = append(fields, model.ScenarioField{
|
||||||
FieldKey: definition.Key,
|
FieldKey: definition.Key,
|
||||||
FieldName: definition.Name,
|
FieldName: definition.Name,
|
||||||
|
SourcePath: definition.SourcePath,
|
||||||
FieldType: definition.Type,
|
FieldType: definition.Type,
|
||||||
Required: definition.Required,
|
Required: definition.Required,
|
||||||
Options: datatypes.JSON(options),
|
Options: datatypes.JSON(options),
|
||||||
|
|||||||
121
sdk/dist/index.d.ts
vendored
Normal file
121
sdk/dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
export type ScenarioInput = Record<string, unknown>;
|
||||||
|
export type OutputField = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
source: string;
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
export type ResultField = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required?: boolean;
|
||||||
|
options?: unknown[];
|
||||||
|
fields?: ResultField[];
|
||||||
|
items?: Omit<ResultField, 'key' | 'name'>;
|
||||||
|
};
|
||||||
|
export type ResultSchema = {
|
||||||
|
fields: ResultField[];
|
||||||
|
};
|
||||||
|
export type NodeField = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
options: unknown[];
|
||||||
|
validation: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
export type PresentationField = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'text' | 'image';
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
export type NodePresentation = {
|
||||||
|
summary: PresentationField[];
|
||||||
|
items: Array<{
|
||||||
|
fields: PresentationField[];
|
||||||
|
}>;
|
||||||
|
opening?: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type KnowledgeCollectionOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
parents?: string[];
|
||||||
|
suggested?: boolean;
|
||||||
|
};
|
||||||
|
export type KnowledgeCollectionStep = {
|
||||||
|
field_key: string;
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
multiple: boolean;
|
||||||
|
from_field?: string;
|
||||||
|
options: KnowledgeCollectionOption[];
|
||||||
|
};
|
||||||
|
export type KnowledgeCollection = {
|
||||||
|
context_fields: NodeField[];
|
||||||
|
context_title?: string;
|
||||||
|
context_hint?: string;
|
||||||
|
selection_title?: string;
|
||||||
|
selection_hint?: string;
|
||||||
|
steps: KnowledgeCollectionStep[];
|
||||||
|
};
|
||||||
|
export type NodeView = {
|
||||||
|
node_key: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
fields?: NodeField[];
|
||||||
|
presentation?: NodePresentation;
|
||||||
|
outputs?: unknown[];
|
||||||
|
collection?: KnowledgeCollection;
|
||||||
|
};
|
||||||
|
export type ScenarioState = {
|
||||||
|
run_id: number;
|
||||||
|
external_ref: string;
|
||||||
|
status: string;
|
||||||
|
final_result?: Record<string, unknown> | null;
|
||||||
|
result_schema: ResultSchema;
|
||||||
|
session_token?: string;
|
||||||
|
node: NodeView;
|
||||||
|
outputs: OutputField[];
|
||||||
|
};
|
||||||
|
export type SDKEvent = 'ready' | 'node_change' | 'form_submit' | 'finish' | 'error';
|
||||||
|
export type CreateOptions = {
|
||||||
|
baseURL?: string;
|
||||||
|
publicKey: string;
|
||||||
|
scenarioKey: string;
|
||||||
|
sopId?: number;
|
||||||
|
input?: ScenarioInput;
|
||||||
|
initialValues?: ScenarioInput;
|
||||||
|
externalRef?: string;
|
||||||
|
};
|
||||||
|
export type FinishOptions = {
|
||||||
|
result?: string;
|
||||||
|
finalResult?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
export declare class SalesScenario {
|
||||||
|
private readonly options;
|
||||||
|
private token;
|
||||||
|
private state?;
|
||||||
|
private handlers;
|
||||||
|
constructor(options: CreateOptions);
|
||||||
|
start(): Promise<ScenarioState>;
|
||||||
|
getState(): ScenarioState;
|
||||||
|
getCurrentNode(): Promise<ScenarioState>;
|
||||||
|
submit(values: ScenarioInput): Promise<ScenarioState>;
|
||||||
|
next(): Promise<ScenarioState>;
|
||||||
|
finish(options?: FinishOptions): Promise<ScenarioState>;
|
||||||
|
reset(): Promise<ScenarioState>;
|
||||||
|
destroy(): void;
|
||||||
|
on(event: SDKEvent, handler: (payload: unknown) => void): () => boolean;
|
||||||
|
private update;
|
||||||
|
private setState;
|
||||||
|
private emit;
|
||||||
|
private request;
|
||||||
|
}
|
||||||
|
export declare function create(options: CreateOptions): SalesScenario;
|
||||||
104
sdk/dist/index.iife.js
vendored
Normal file
104
sdk/dist/index.iife.js
vendored
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use strict";
|
||||||
|
var IqudooSalesScenario = (() => {
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
|
||||||
|
// src/index.ts
|
||||||
|
var index_exports = {};
|
||||||
|
__export(index_exports, {
|
||||||
|
SalesScenario: () => SalesScenario,
|
||||||
|
create: () => create
|
||||||
|
});
|
||||||
|
var SalesScenario = class {
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
options;
|
||||||
|
token = "";
|
||||||
|
state;
|
||||||
|
handlers = /* @__PURE__ */ new Map();
|
||||||
|
async start() {
|
||||||
|
const state = await this.request(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`, { method: "POST", body: JSON.stringify({ public_key: this.options.publicKey, sop_id: this.options.sopId, input: this.options.input || {}, initial_values: this.options.initialValues || {}, external_ref: this.options.externalRef || "" }) });
|
||||||
|
if (state.session_token) this.token = state.session_token;
|
||||||
|
this.setState(state);
|
||||||
|
this.emit("ready", state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
getState() {
|
||||||
|
if (!this.state) throw new Error("SDK has not started");
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
async getCurrentNode() {
|
||||||
|
return this.update(`/public/runs/${this.getState().run_id}/current`);
|
||||||
|
}
|
||||||
|
async submit(values) {
|
||||||
|
const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) });
|
||||||
|
this.emit("form_submit", { values, state });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
async next() {
|
||||||
|
return this.update(`/public/runs/${this.getState().run_id}/next`, { method: "POST" });
|
||||||
|
}
|
||||||
|
async finish(options = {}) {
|
||||||
|
const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: "POST", body: JSON.stringify({ result: options.result || "", final_result: options.finalResult ?? null }) });
|
||||||
|
this.emit("finish", state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
async reset() {
|
||||||
|
return this.update(`/public/runs/${this.getState().run_id}/reset`, { method: "POST" });
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
this.handlers.clear();
|
||||||
|
this.token = "";
|
||||||
|
this.state = void 0;
|
||||||
|
}
|
||||||
|
on(event, handler) {
|
||||||
|
const handlers = this.handlers.get(event) || /* @__PURE__ */ new Set();
|
||||||
|
handlers.add(handler);
|
||||||
|
this.handlers.set(event, handlers);
|
||||||
|
return () => handlers.delete(handler);
|
||||||
|
}
|
||||||
|
async update(path, init = {}) {
|
||||||
|
const state = await this.request(path, init);
|
||||||
|
this.setState(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
setState(state) {
|
||||||
|
this.state = state;
|
||||||
|
this.emit("node_change", state.node);
|
||||||
|
}
|
||||||
|
emit(event, payload) {
|
||||||
|
this.handlers.get(event)?.forEach((handler) => handler(payload));
|
||||||
|
}
|
||||||
|
async request(path, init) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${(this.options.baseURL || "").replace(/\/$/, "")}${path}`, { ...init, headers: { "Content-Type": "application/json", ...this.token ? { Authorization: `Bearer ${this.token}` } : {} } });
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error(body.message || `Request failed: ${response.status}`);
|
||||||
|
return body.data || body;
|
||||||
|
} catch (error) {
|
||||||
|
this.emit("error", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function create(options) {
|
||||||
|
return new SalesScenario(options);
|
||||||
|
}
|
||||||
|
return __toCommonJS(index_exports);
|
||||||
|
})();
|
||||||
35
sdk/dist/index.js
vendored
Normal file
35
sdk/dist/index.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export class SalesScenario {
|
||||||
|
options;
|
||||||
|
token = '';
|
||||||
|
state;
|
||||||
|
handlers = new Map();
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
async start() { const state = await this.request(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`, { method: 'POST', body: JSON.stringify({ public_key: this.options.publicKey, sop_id: this.options.sopId, input: this.options.input || {}, initial_values: this.options.initialValues || {}, external_ref: this.options.externalRef || '' }) }); if (state.session_token)
|
||||||
|
this.token = state.session_token; this.setState(state); this.emit('ready', state); return state; }
|
||||||
|
getState() { if (!this.state)
|
||||||
|
throw new Error('SDK has not started'); return this.state; }
|
||||||
|
async getCurrentNode() { return this.update(`/public/runs/${this.getState().run_id}/current`); }
|
||||||
|
async submit(values) { const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) }); this.emit('form_submit', { values, state }); return state; }
|
||||||
|
async next() { return this.update(`/public/runs/${this.getState().run_id}/next`, { method: 'POST' }); }
|
||||||
|
async finish(options = {}) { const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: 'POST', body: JSON.stringify({ result: options.result || '', final_result: options.finalResult ?? null }) }); this.emit('finish', state); return state; }
|
||||||
|
async reset() { return this.update(`/public/runs/${this.getState().run_id}/reset`, { method: 'POST' }); }
|
||||||
|
destroy() { this.handlers.clear(); this.token = ''; this.state = undefined; }
|
||||||
|
on(event, handler) { const handlers = this.handlers.get(event) || new Set(); handlers.add(handler); this.handlers.set(event, handlers); return () => handlers.delete(handler); }
|
||||||
|
async update(path, init = {}) { const state = await this.request(path, init); this.setState(state); return state; }
|
||||||
|
setState(state) { this.state = state; this.emit('node_change', state.node); }
|
||||||
|
emit(event, payload) { this.handlers.get(event)?.forEach(handler => handler(payload)); }
|
||||||
|
async request(path, init) { try {
|
||||||
|
const response = await fetch(`${(this.options.baseURL || '').replace(/\/$/, '')}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}) } });
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(body.message || `Request failed: ${response.status}`);
|
||||||
|
return (body.data || body);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
throw error;
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
export function create(options) { return new SalesScenario(options); }
|
||||||
514
sdk/package-lock.json
generated
Normal file
514
sdk/package-lock.json
generated
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
{
|
||||||
|
"name": "@iqudoo/sales-scenario-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@iqudoo/sales-scenario-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
sdk/package.json
Normal file
23
sdk/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@iqudoo/sales-scenario-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --format=iife --global-name=IqudooSalesScenario --outfile=dist/index.iife.js",
|
||||||
|
"build:esm": "tsc -p tsconfig.json",
|
||||||
|
"build:iife": "esbuild src/index.ts --bundle --format=iife --global-name=IqudooSalesScenario --outfile=dist/index.iife.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
sdk/src/index.ts
Normal file
37
sdk/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type ScenarioInput = Record<string, unknown>
|
||||||
|
export type OutputField = { key:string; name:string; type:string; source:string; value:unknown }
|
||||||
|
export type ResultField = { key:string; name:string; type:string; required?:boolean; options?:unknown[]; fields?:ResultField[]; items?:Omit<ResultField,'key'|'name'> }
|
||||||
|
export type ResultSchema = { fields:ResultField[] }
|
||||||
|
export type NodeField = { key:string; name:string; type:string; required:boolean; options:unknown[]; validation:Record<string,unknown> }
|
||||||
|
export type PresentationField = { key:string; label:string; kind:'text'|'image'; value:unknown }
|
||||||
|
export type NodePresentation = { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} }
|
||||||
|
export type KnowledgeCollectionOption = { value:string; label:string; parents?:string[]; suggested?:boolean }
|
||||||
|
export type KnowledgeCollectionStep = { field_key:string; name:string; required:boolean; multiple:boolean; from_field?:string; options:KnowledgeCollectionOption[] }
|
||||||
|
export type KnowledgeCollection = { context_fields:NodeField[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] }
|
||||||
|
export type NodeView = { node_key:string; type:string; title:string; content:string; fields?:NodeField[]; presentation?:NodePresentation; outputs?:unknown[]; collection?:KnowledgeCollection }
|
||||||
|
export type ScenarioState = { run_id:number; external_ref:string; status:string; final_result?:Record<string, unknown>|null; result_schema:ResultSchema; session_token?:string; node:NodeView; outputs:OutputField[] }
|
||||||
|
export type SDKEvent = 'ready'|'node_change'|'form_submit'|'finish'|'error'
|
||||||
|
export type CreateOptions = { baseURL?:string; publicKey:string; scenarioKey:string; sopId?:number; input?:ScenarioInput; initialValues?:ScenarioInput; externalRef?:string }
|
||||||
|
export type FinishOptions = { result?:string; finalResult?:Record<string, unknown>|null }
|
||||||
|
|
||||||
|
export class SalesScenario {
|
||||||
|
private token=''
|
||||||
|
private state?:ScenarioState
|
||||||
|
private handlers=new Map<SDKEvent,Set<(payload:unknown)=>void>>()
|
||||||
|
constructor(private readonly options:CreateOptions){}
|
||||||
|
async start(){const state=await this.request<ScenarioState>(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`,{method:'POST',body:JSON.stringify({public_key:this.options.publicKey,sop_id:this.options.sopId,input:this.options.input||{},initial_values:this.options.initialValues||{},external_ref:this.options.externalRef||''})});if(state.session_token)this.token=state.session_token;this.setState(state);this.emit('ready',state);return state}
|
||||||
|
getState(){if(!this.state)throw new Error('SDK has not started');return this.state}
|
||||||
|
async getCurrentNode(){return this.update(`/public/runs/${this.getState().run_id}/current`)}
|
||||||
|
async submit(values:ScenarioInput){const state=await this.update(`/public/runs/${this.getState().run_id}/submit`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,answers:values})});this.emit('form_submit',{values,state});return state}
|
||||||
|
async next(){return this.update(`/public/runs/${this.getState().run_id}/next`,{method:'POST'})}
|
||||||
|
async finish(options:FinishOptions={}){const state=await this.update(`/public/runs/${this.getState().run_id}/finish`,{method:'POST',body:JSON.stringify({result:options.result||'',final_result:options.finalResult??null})});this.emit('finish',state);return state}
|
||||||
|
async reset(){return this.update(`/public/runs/${this.getState().run_id}/reset`,{method:'POST'})}
|
||||||
|
destroy(){this.handlers.clear();this.token='';this.state=undefined}
|
||||||
|
on(event:SDKEvent,handler:(payload:unknown)=>void){const handlers=this.handlers.get(event)||new Set();handlers.add(handler);this.handlers.set(event,handlers);return()=>handlers.delete(handler)}
|
||||||
|
private async update(path:string,init:RequestInit={}){const state=await this.request<ScenarioState>(path,init);this.setState(state);return state}
|
||||||
|
private setState(state:ScenarioState){this.state=state;this.emit('node_change',state.node)}
|
||||||
|
private emit(event:SDKEvent,payload:unknown){this.handlers.get(event)?.forEach(handler=>handler(payload))}
|
||||||
|
private async request<T>(path:string,init:RequestInit){try{const response=await fetch(`${(this.options.baseURL||'').replace(/\/$/,'')}${path}`,{...init,headers:{'Content-Type':'application/json',...(this.token?{Authorization:`Bearer ${this.token}`}:{})}});const body=await response.json();if(!response.ok)throw new Error(body.message||`Request failed: ${response.status}`);return (body.data||body) as T}catch(error){this.emit('error',error);throw error}}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function create(options:CreateOptions){return new SalesScenario(options)}
|
||||||
13
sdk/tsconfig.json
Normal file
13
sdk/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
1
web/dist/assets/BookOutlined-Canuw-q-.js
vendored
1
web/dist/assets/BookOutlined-Canuw-q-.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-260 72h96v209.9L621.5 312 572 347.4V136zm220 752H232V136h280v296.9c0 3.3 1 6.6 3 9.3a15.9 15.9 0 0022.3 3.7l83.8-59.9 81.4 59.4c2.7 2 6 3.1 9.4 3.1 8.8 0 16-7.2 16-16V136h64v752z`}}]},name:`book`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`BookOutlined`,a.inheritAttrs=!1;export{a as t};
|
|
||||||
1
web/dist/assets/CopyOutlined-lDB-4CmK.js
vendored
Normal file
1
web/dist/assets/CopyOutlined-lDB-4CmK.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z`}}]},name:`copy`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`CopyOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
web/dist/assets/DashboardView-Btr47lQ_.css
vendored
1
web/dist/assets/DashboardView-Btr47lQ_.css
vendored
@@ -1 +0,0 @@
|
|||||||
.metric-strip[data-v-81958fcc]{grid-template-columns:repeat(4,1fr);display:grid;overflow:hidden}.metric[data-v-81958fcc]{border-right:1px solid var(--line);align-items:center;gap:16px;min-height:116px;padding:22px;display:flex}.metric[data-v-81958fcc]:last-child{border-right:0}.metric-icon[data-v-81958fcc],.completion[data-v-81958fcc]{border-radius:5px;place-items:center;width:42px;height:42px;font-size:19px;display:grid}.metric-icon.green[data-v-81958fcc]{color:#16775b;background:#e5f3ee}.metric-icon.coral[data-v-81958fcc]{color:#c4573f;background:#faece7}.metric-icon.amber[data-v-81958fcc]{color:#a86e1e;background:#f8efdf}.completion[data-v-81958fcc]{color:#202825;background:#e8ecea;font-size:12px;font-weight:800}.metric div[data-v-81958fcc]{flex-direction:column;display:flex}.metric b[data-v-81958fcc]{font-family:Noto Serif SC,serif;font-size:28px;line-height:1}.metric small[data-v-81958fcc]{color:var(--muted);margin-top:8px}.work-grid[data-v-81958fcc]{grid-template-columns:1.05fr .95fr;gap:18px;margin-top:18px;display:grid}.action-panel[data-v-81958fcc],.doctrine-panel[data-v-81958fcc]{padding:22px}.panel-kicker[data-v-81958fcc]{color:#7c8883;text-transform:uppercase;margin-bottom:14px;font-size:11px;font-weight:700}.action-panel button[data-v-81958fcc]{width:100%;color:var(--ink);text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:0 0;justify-content:space-between;align-items:center;padding:15px 0;display:flex}.action-panel button[data-v-81958fcc]:last-child{border-bottom:0}.action-panel button[data-v-81958fcc]:hover{color:var(--green)}.action-panel button span[data-v-81958fcc]{flex-direction:column;font-weight:650;display:flex}.action-panel button small[data-v-81958fcc]{color:var(--muted);margin-top:4px;font-weight:400}.doctrine-panel[data-v-81958fcc]{color:#fff;background:#202825;border-color:#202825}.doctrine-panel .panel-kicker[data-v-81958fcc]{color:#89a099}blockquote[data-v-81958fcc]{margin:30px 0 42px;font-family:Noto Serif SC,serif;font-size:22px;line-height:1.65}.flow-note[data-v-81958fcc]{color:#9cafaa;align-items:center;font-size:12px;display:flex}.flow-note i[data-v-81958fcc]{background:#4a5a54;flex:1;height:1px;margin:0 10px}@media (width<=900px){.metric-strip[data-v-81958fcc]{grid-template-columns:repeat(2,1fr)}.metric[data-v-81958fcc]:nth-child(2){border-right:0}.metric[data-v-81958fcc]:nth-child(-n+2){border-bottom:1px solid var(--line)}.work-grid[data-v-81958fcc]{grid-template-columns:1fr}}@media (width<=520px){.metric[data-v-81958fcc]{min-height:100px;padding:16px}.metric b[data-v-81958fcc]{font-size:24px}.metric-icon[data-v-81958fcc],.completion[data-v-81958fcc]{display:none}}
|
|
||||||
1
web/dist/assets/DashboardView-CFwHesUj.css
vendored
Normal file
1
web/dist/assets/DashboardView-CFwHesUj.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.metric-strip[data-v-46574c4c]{grid-template-columns:repeat(4,1fr);display:grid;overflow:hidden}.metric[data-v-46574c4c]{border-right:1px solid var(--line);align-items:center;gap:16px;min-height:116px;padding:22px;display:flex}.metric[data-v-46574c4c]:last-child{border-right:0}.metric-icon[data-v-46574c4c],.completion[data-v-46574c4c]{border-radius:5px;place-items:center;width:42px;height:42px;font-size:19px;display:grid}.metric-icon.green[data-v-46574c4c]{color:#16775b;background:#e5f3ee}.metric-icon.coral[data-v-46574c4c]{color:#c4573f;background:#faece7}.metric-icon.amber[data-v-46574c4c]{color:#a86e1e;background:#f8efdf}.completion[data-v-46574c4c]{color:#202825;background:#e8ecea;font-size:12px;font-weight:800}.metric div[data-v-46574c4c]{flex-direction:column;display:flex}.metric b[data-v-46574c4c]{font-family:Noto Serif SC,serif;font-size:28px;line-height:1}.metric small[data-v-46574c4c]{color:var(--muted);margin-top:8px}.work-grid[data-v-46574c4c]{grid-template-columns:1.05fr .95fr;gap:18px;margin-top:18px;display:grid}.action-panel[data-v-46574c4c],.doctrine-panel[data-v-46574c4c]{padding:22px}.panel-kicker[data-v-46574c4c]{color:#7c8883;text-transform:uppercase;margin-bottom:14px;font-size:11px;font-weight:700}.action-panel button[data-v-46574c4c]{width:100%;color:var(--ink);text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:0 0;justify-content:space-between;align-items:center;padding:15px 0;display:flex}.action-panel button[data-v-46574c4c]:last-child{border-bottom:0}.action-panel button[data-v-46574c4c]:hover{color:var(--green)}.action-panel button span[data-v-46574c4c]{flex-direction:column;font-weight:650;display:flex}.action-panel button small[data-v-46574c4c]{color:var(--muted);margin-top:4px;font-weight:400}.doctrine-panel[data-v-46574c4c]{color:#fff;background:#202825;border-color:#202825}.doctrine-panel .panel-kicker[data-v-46574c4c]{color:#89a099}blockquote[data-v-46574c4c]{margin:30px 0 42px;font-family:Noto Serif SC,serif;font-size:22px;line-height:1.65}.flow-note[data-v-46574c4c]{color:#9cafaa;align-items:center;font-size:12px;display:flex}.flow-note i[data-v-46574c4c]{background:#4a5a54;flex:1;height:1px;margin:0 10px}@media (width<=900px){.metric-strip[data-v-46574c4c]{grid-template-columns:repeat(2,1fr)}.metric[data-v-46574c4c]:nth-child(2){border-right:0}.metric[data-v-46574c4c]:nth-child(-n+2){border-bottom:1px solid var(--line)}.work-grid[data-v-46574c4c]{grid-template-columns:1fr}}@media (width<=520px){.metric[data-v-46574c4c]{min-height:100px;padding:16px}.metric b[data-v-46574c4c]{font-size:24px}.metric-icon[data-v-46574c4c],.completion[data-v-46574c4c]{display:none}}
|
||||||
1
web/dist/assets/DashboardView-DJ4b96AE.js
vendored
1
web/dist/assets/DashboardView-DJ4b96AE.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,i as l,j as u,lt as d,r as f,t as p,tt as m,vt as h}from"./client-Bk7n1O1W.js";import{t as g}from"./auth-D6Tj4g1X.js";import{t as _}from"./PlusOutlined-CosM7zFe.js";import{n as v,t as y}from"./AppstoreOutlined-BD2QJkU0.js";import{t as b}from"./PlayCircleOutlined-DjeS67m_.js";import{h as x}from"./useApi-CROJJdhE-C8LJOf_8.js";var S={class:`page-shell`},C={class:`page-heading`},w={key:0,class:`page-actions`},T={class:`metric-strip surface`},E={class:`metric`},D={class:`metric-icon green`},O={class:`metric`},k={class:`metric-icon coral`},A={class:`metric`},j={class:`metric-icon amber`},M={class:`metric`},N={class:`completion`},P={class:`work-grid`},F={class:`surface action-panel`},I=f(r({__name:`DashboardView`,setup(r){let f=x(),I=g(),L=h(!0),R=h({scenarios:0,published_sops:0,runs:0,completed_runs:0});return c(async()=>{try{R.value=await p.get(`/dashboard/summary`)}finally{L.value=!1}}),(r,c)=>{let p=m(`a-button`),h=m(`a-skeleton`);return o(),u(`div`,S,[i(`div`,C,[c[7]||=i(`div`,null,[i(`h1`,null,`今天从经验开始`),i(`p`,null,`查看知识沉淀和一线执行的最新状态。`)],-1),s(I).can(`scenario.edit`)?(o(),u(`div`,w,[n(p,{type:`primary`,onClick:c[0]||=e=>s(f).push(`/scenarios`)},{default:d(()=>[n(s(_)),c[6]||=a(`创建场景`,-1)]),_:1})])):e(``,!0)]),n(h,{loading:L.value,active:``},{default:d(()=>[i(`section`,T,[i(`div`,E,[i(`span`,D,[n(s(y))]),i(`div`,null,[i(`b`,null,t(R.value.scenarios),1),c[8]||=i(`small`,null,`可用场景`,-1)])]),i(`div`,O,[i(`span`,k,[n(s(l))]),i(`div`,null,[i(`b`,null,t(R.value.published_sops),1),c[9]||=i(`small`,null,`已发布 SOP`,-1)])]),i(`div`,A,[i(`span`,j,[n(s(b))]),i(`div`,null,[i(`b`,null,t(R.value.runs),1),c[10]||=i(`small`,null,`累计执行`,-1)])]),i(`div`,M,[i(`span`,N,t(R.value.runs?Math.round(R.value.completed_runs/R.value.runs*100):0)+`%`,1),i(`div`,null,[i(`b`,null,t(R.value.completed_runs),1),c[11]||=i(`small`,null,`完成执行`,-1)])])])]),_:1},8,[`loading`]),i(`section`,P,[i(`div`,F,[c[17]||=i(`div`,{class:`panel-kicker`},`常用入口`,-1),s(I).can(`scenario.edit`)?(o(),u(`button`,{key:0,onClick:c[1]||=e=>s(f).push(`/scenarios`)},[c[12]||=i(`span`,null,[a(`配置新的业务场景`),i(`small`,null,`定义字段、目标和触发条件`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.review`)?(o(),u(`button`,{key:1,onClick:c[2]||=e=>s(f).push(`/reviews`)},[c[13]||=i(`span`,null,[a(`处理待审核 SOP`),i(`small`,null,`核对流程、口径和风险出口`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.execute`)?(o(),u(`button`,{key:2,onClick:c[3]||=e=>s(f).push(`/execute`)},[c[14]||=i(`span`,null,[a(`开始执行已发布 SOP`),i(`small`,null,`根据客户回答逐步推进`)],-1),n(s(v))])):e(``,!0),s(I).can(`knowledge.edit`)?(o(),u(`button`,{key:3,onClick:c[4]||=e=>s(f).push(`/knowledge`)},[c[15]||=i(`span`,null,[a(`维护审核知识卡`),i(`small`,null,`统一口径与风险提示`)],-1),n(s(v))])):e(``,!0),s(I).can(`runs.view_all`)||s(I).can(`runs.view_own`)?(o(),u(`button`,{key:4,onClick:c[5]||=e=>s(f).push(`/runs`)},[c[16]||=i(`span`,null,[a(`复盘执行记录`),i(`small`,null,`查看节点轨迹、回答与反馈`)],-1),n(s(v))])):e(``,!0)]),c[18]||=i(`div`,{class:`surface doctrine-panel`},[i(`div`,{class:`panel-kicker`},`平台原则`),i(`blockquote`,null,`一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。`),i(`div`,{class:`flow-note`},[i(`span`,null,`创建`),i(`i`),i(`span`,null,`发布`),i(`i`),i(`span`,null,`执行`),i(`i`),i(`span`,null,`复盘`)])],-1)])])}}}),[[`__scopeId`,`data-v-81958fcc`]]);export{I as default};
|
|
||||||
1
web/dist/assets/DashboardView-Vhr2bwL8.js
vendored
Normal file
1
web/dist/assets/DashboardView-Vhr2bwL8.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,i as l,j as u,lt as d,r as f,t as p,tt as m,vt as h}from"./client-Bk7n1O1W.js";import{t as g}from"./auth-D6Tj4g1X.js";import{t as _}from"./PlusOutlined-CosM7zFe.js";import{n as v,t as y}from"./AppstoreOutlined-BD2QJkU0.js";import{t as b}from"./PlayCircleOutlined-DjeS67m_.js";import{h as x}from"./useApi-CROJJdhE-C8LJOf_8.js";var S={class:`page-shell`},C={class:`page-heading`},w={key:0,class:`page-actions`},T={class:`metric-strip surface`},E={class:`metric`},D={class:`metric-icon green`},O={class:`metric`},k={class:`metric-icon coral`},A={class:`metric`},j={class:`metric-icon amber`},M={class:`metric`},N={class:`completion`},P={class:`work-grid`},F={class:`surface action-panel`},I=f(r({__name:`DashboardView`,setup(r){let f=x(),I=g(),L=h(!0),R=h({scenarios:0,published_sops:0,runs:0,completed_runs:0});return c(async()=>{try{R.value=await p.get(`/dashboard/summary`)}finally{L.value=!1}}),(r,c)=>{let p=m(`a-button`),h=m(`a-skeleton`);return o(),u(`div`,S,[i(`div`,C,[c[6]||=i(`div`,null,[i(`h1`,null,`今天从经验开始`),i(`p`,null,`查看知识沉淀和一线执行的最新状态。`)],-1),s(I).can(`scenario.edit`)?(o(),u(`div`,w,[n(p,{type:`primary`,onClick:c[0]||=e=>s(f).push(`/scenarios`)},{default:d(()=>[n(s(_)),c[5]||=a(`创建场景`,-1)]),_:1})])):e(``,!0)]),n(h,{loading:L.value,active:``},{default:d(()=>[i(`section`,T,[i(`div`,E,[i(`span`,D,[n(s(y))]),i(`div`,null,[i(`b`,null,t(R.value.scenarios),1),c[7]||=i(`small`,null,`可用场景`,-1)])]),i(`div`,O,[i(`span`,k,[n(s(l))]),i(`div`,null,[i(`b`,null,t(R.value.published_sops),1),c[8]||=i(`small`,null,`可用 SOP`,-1)])]),i(`div`,A,[i(`span`,j,[n(s(b))]),i(`div`,null,[i(`b`,null,t(R.value.runs),1),c[9]||=i(`small`,null,`累计执行`,-1)])]),i(`div`,M,[i(`span`,N,t(R.value.runs?Math.round(R.value.completed_runs/R.value.runs*100):0)+`%`,1),i(`div`,null,[i(`b`,null,t(R.value.completed_runs),1),c[10]||=i(`small`,null,`完成执行`,-1)])])])]),_:1},8,[`loading`]),i(`section`,P,[i(`div`,F,[c[15]||=i(`div`,{class:`panel-kicker`},`常用入口`,-1),s(I).can(`scenario.edit`)?(o(),u(`button`,{key:0,onClick:c[1]||=e=>s(f).push(`/scenarios`)},[c[11]||=i(`span`,null,[a(`配置新的业务场景`),i(`small`,null,`定义字段、目标和触发条件`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.execute`)?(o(),u(`button`,{key:1,onClick:c[2]||=e=>s(f).push(`/execute`)},[c[12]||=i(`span`,null,[a(`开始执行 SOP`),i(`small`,null,`根据客户回答逐步推进`)],-1),n(s(v))])):e(``,!0),s(I).can(`knowledge.edit`)?(o(),u(`button`,{key:2,onClick:c[3]||=e=>s(f).push(`/scenarios`)},[c[13]||=i(`span`,null,[a(`配置场景知识卡`),i(`small`,null,`在所属场景内维护输入关联与话术`)],-1),n(s(v))])):e(``,!0),s(I).can(`runs.view_all`)||s(I).can(`runs.view_own`)?(o(),u(`button`,{key:3,onClick:c[4]||=e=>s(f).push(`/runs`)},[c[14]||=i(`span`,null,[a(`复盘执行记录`),i(`small`,null,`查看节点轨迹、回答与反馈`)],-1),n(s(v))])):e(``,!0)]),c[16]||=i(`div`,{class:`surface doctrine-panel`},[i(`div`,{class:`panel-kicker`},`平台原则`),i(`blockquote`,null,`一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。`),i(`div`,{class:`flow-note`},[i(`span`,null,`创建`),i(`i`),i(`span`,null,`配置`),i(`i`),i(`span`,null,`执行`),i(`i`),i(`span`,null,`复盘`)])],-1)])])}}}),[[`__scopeId`,`data-v-46574c4c`]]);export{I as default};
|
||||||
1
web/dist/assets/ExecuteView-BrNPNrQz.js
vendored
1
web/dist/assets/ExecuteView-BrNPNrQz.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-CAUSQPH9.js
vendored
Normal file
1
web/dist/assets/ExecuteView-CAUSQPH9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-CTC6GSK6.css
vendored
1
web/dist/assets/ExecuteView-CTC6GSK6.css
vendored
@@ -1 +0,0 @@
|
|||||||
.execution-shell[data-v-937f7610]{max-width:1220px}.sop-catalog[data-v-937f7610]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-937f7610]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-937f7610]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-937f7610]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-937f7610]{color:var(--green);font-weight:650}.sop-entry h2[data-v-937f7610]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-937f7610]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-937f7610]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-937f7610]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-937f7610]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-937f7610]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-937f7610]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-937f7610]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-937f7610]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-937f7610]{color:#9eada7;font-size:12px}.privacy-note[data-v-937f7610]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-937f7610]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-937f7610]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-937f7610]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-937f7610]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-937f7610]{padding:44px 0 26px}.node-content small[data-v-937f7610]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-937f7610]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-937f7610]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-937f7610]{max-width:680px}.choice-group[data-v-937f7610]{flex-wrap:wrap;display:flex}.run-actions[data-v-937f7610]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.run-actions>div[data-v-937f7610]{align-items:center;gap:9px;display:flex}.completed-state[data-v-937f7610]{text-align:center;padding:40px 0}.completed-state>span[data-v-937f7610]{color:var(--green);font-size:50px}.completed-state h3[data-v-937f7610]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-937f7610]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-937f7610]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-937f7610]{font-size:12px;font-weight:650}.quick-feedback[data-v-937f7610] .ant-rate{font-size:18px}.quick-feedback[data-v-937f7610] .ant-input{grid-column:1/3}.feedback-thanks[data-v-937f7610]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}.knowledge-snapshot[data-v-937f7610]{border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:0 0 24px}.knowledge-head[data-v-937f7610]{justify-content:space-between;align-items:center;padding:12px 0;display:flex}.knowledge-head span[data-v-937f7610]{color:var(--muted);font-size:11px}.standard-copy[data-v-937f7610],.knowledge-note[data-v-937f7610]{border-left:3px solid var(--green);background:#f2f7f5;padding:14px 16px}.standard-copy small[data-v-937f7610],.knowledge-note small[data-v-937f7610]{font-size:10px;font-weight:750}.standard-copy p[data-v-937f7610],.knowledge-note p[data-v-937f7610]{margin:6px 0 0;line-height:1.7}.knowledge-note[data-v-937f7610]{background:#fbf3f0;border-left-color:#c4573f;margin-top:8px}.knowledge-note.risk[data-v-937f7610]{background:#fbf6eb;border-left-color:#bd7b24}@media (width<=800px){.sop-catalog[data-v-937f7610],.run-workspace[data-v-937f7610]{grid-template-columns:1fr}.run-context[data-v-937f7610]{position:static}.conversation[data-v-937f7610]{padding:22px}.run-actions[data-v-937f7610]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-937f7610]{display:none}.quick-feedback[data-v-937f7610]{grid-template-columns:1fr}.quick-feedback[data-v-937f7610] .ant-input{grid-column:auto}}
|
|
||||||
1
web/dist/assets/ExecuteView-D6wWGKCU.css
vendored
Normal file
1
web/dist/assets/ExecuteView-D6wWGKCU.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/EyeOutlined-C1VmLQCG.js
vendored
1
web/dist/assets/EyeOutlined-C1VmLQCG.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z`}}]},name:`check`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`CheckOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M686.7 638.6L544.1 535.5V288c0-4.4-3.6-8-8-8H488c-4.4 0-8 3.6-8 8v275.4c0 2.6 1.2 5 3.3 6.5l165.4 120.6c3.6 2.6 8.6 1.8 11.2-1.7l28.6-39c2.6-3.7 1.8-8.7-1.8-11.2z`}}]},name:`clock-circle`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`ClockCircleOutlined`,l.inheritAttrs=!1;var u={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z`}}]},name:`eye`,theme:`outlined`};function d(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){f(e,t,n[t])})}return e}function f(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var p=function(n,r){var i=d({},n,r.attrs);return e(t,d({},i,{icon:u}),null)};p.displayName=`EyeOutlined`,p.inheritAttrs=!1;export{l as n,a as r,p as t};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user