verify: 合并宽屏自适应与推理输出改造(测试服验证用)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot DingTalk AI card probe (test bot, private IM_ROBOT space).
|
||||
|
||||
Sends two cards to the owner's private robot chat so PC width can be compared:
|
||||
A: body carries sys_full_json_obj = {"config":{"autoLayout":true}}
|
||||
B: control, same body without the layout field
|
||||
Card body mirrors internal/channels/dingtalk/card.go buildCardBody() exactly.
|
||||
|
||||
Credentials are read from the running container's environment and are never
|
||||
printed. Only masked ids, HTTP statuses and DingTalk response bodies are shown.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
CONTAINER = "dobotai-dingtalk"
|
||||
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
CREATE_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
STREAM_URL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
OWNER_ID = "243143382023108067" # 姚章浩 (channel_actors.dingtalk, test tenant)
|
||||
WIDE = json.dumps({"config": {"autoLayout": True}}, separators=(",", ":"))
|
||||
|
||||
CONTENT = (
|
||||
"**{tag}**\n\n"
|
||||
"这是一条 PC 宽度对照测试卡片,由测试机器人在私聊里直接推送。\n\n"
|
||||
"- 投放体里的布局字段:{field}\n"
|
||||
"- 期望:{expect}\n"
|
||||
"- 请在 PC 端对比两张卡片的宽度差异\n\n"
|
||||
"移动端请忽略,宽屏只影响桌面端渲染。"
|
||||
)
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
out = subprocess.run(
|
||||
["docker", "exec", CONTAINER, "printenv", key],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
if not out:
|
||||
sys.exit(f"missing env {key}")
|
||||
return out
|
||||
|
||||
|
||||
def call(method: str, url: str, payload: dict, token: str = "") -> tuple[int, str]:
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("x-acs-dingtalk-access-token", token)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.status, resp.read(4096).decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read(4096).decode("utf-8", "replace")
|
||||
|
||||
|
||||
def mask(v: str) -> str:
|
||||
return v[:6] + "..." + v[-4:] if len(v) > 12 else "***"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app_key, app_secret = env("DINGTALK_CLIENT_ID"), env("DINGTALK_CLIENT_SECRET")
|
||||
tpl = env("DINGTALK_CARD_TEMPLATE_ID")
|
||||
print(f"app_key={mask(app_key)} template={mask(tpl)} target={mask(OWNER_ID)}")
|
||||
|
||||
status, body = call("POST", TOKEN_URL, {"appKey": app_key, "appSecret": app_secret})
|
||||
token = json.loads(body).get("accessToken", "")
|
||||
print(f"token: http={status} got={'yes' if token else 'NO'}")
|
||||
if not token:
|
||||
sys.exit("no token")
|
||||
|
||||
for tag, with_wide in (("A · 已带 autoLayout=true", True), ("B · 对照组(无布局字段)", False)):
|
||||
param_map = {
|
||||
"content": "",
|
||||
"feedbackSelected": "",
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
}
|
||||
if with_wide:
|
||||
param_map["sys_full_json_obj"] = WIDE
|
||||
out_track_id = str(uuid.uuid4())
|
||||
card_body = {
|
||||
"cardTemplateId": tpl,
|
||||
"outTrackId": out_track_id,
|
||||
"callbackType": "STREAM",
|
||||
"cardData": {"cardParamMap": param_map},
|
||||
"userIdType": 1,
|
||||
"openSpaceId": "dtv1.card//IM_ROBOT." + OWNER_ID,
|
||||
"imRobotOpenSpaceModel": {"supportForward": True},
|
||||
"imRobotOpenDeliverModel": {
|
||||
"robotCode": app_key,
|
||||
"spaceType": "IM_ROBOT",
|
||||
"extension": {},
|
||||
},
|
||||
}
|
||||
st, rb = call("POST", CREATE_URL, card_body, token)
|
||||
print(f"[{tag}] create http={st} resp={rb[:300]}")
|
||||
|
||||
text = CONTENT.format(
|
||||
tag=tag,
|
||||
field=("sys_full_json_obj = " + WIDE) if with_wide else "(无)",
|
||||
expect="PC 端铺满窗口(宽屏)" if with_wide else "PC 端窄卡片(默认宽度)",
|
||||
)
|
||||
stream_body = {
|
||||
"outTrackId": out_track_id,
|
||||
"guid": str(uuid.uuid4()),
|
||||
"key": "content",
|
||||
"content": text,
|
||||
"isFull": True,
|
||||
"isFinalize": True,
|
||||
"isError": False,
|
||||
}
|
||||
st2, rb2 = call("PUT", STREAM_URL, stream_body, token)
|
||||
print(f"[{tag}] finalize http={st2} resp={rb2[:200]} outTrackId={mask(out_track_id)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
\pset pager off
|
||||
SELECT table_schema || '.' || table_name AS t
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
\pset pager off
|
||||
\echo == columns ==
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'channel_actors'
|
||||
ORDER BY ordinal_position;
|
||||
\echo == rows ==
|
||||
SELECT * FROM channel_actors ORDER BY 1 LIMIT 6;
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test-server switch to the wide-screen (autoLayout) card image.
|
||||
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
|
||||
# substitution is exactly 1 before and exactly 1 after. Read-only readback
|
||||
# at the end. No secrets are printed.
|
||||
set -euo pipefail
|
||||
|
||||
NEW_IMAGE="dobotai-dingtalk:local-cardfix-ba090d17"
|
||||
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
|
||||
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
|
||||
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-widescreen"
|
||||
STAMP="$(date +%Y%m%dT%H%M%S)"
|
||||
BACKUP_DIR="/opt/dobotai/container/backups"
|
||||
BACKUP="$BACKUP_DIR/runtime.env.before-card-widescreen.$STAMP"
|
||||
|
||||
echo "== preflight =="
|
||||
docker image inspect "$NEW_IMAGE" >/dev/null
|
||||
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
|
||||
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
|
||||
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
|
||||
echo "old_image=$OLD_IMAGE"
|
||||
echo "new_image=$NEW_IMAGE"
|
||||
echo "rollback_tag=$ROLLBACK_TAG"
|
||||
|
||||
echo "== tag rollback target =="
|
||||
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
echo "rollback tag already exists, keeping it (no overwrite)"
|
||||
else
|
||||
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
|
||||
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
|
||||
fi
|
||||
|
||||
echo "== backup runtime.env =="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
|
||||
echo "backup=$BACKUP"
|
||||
|
||||
echo "== rewrite DOBOTAI_IMAGE =="
|
||||
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
|
||||
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
|
||||
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
|
||||
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" "$RUNTIME_ENV"
|
||||
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
|
||||
|
||||
echo "== recreate container =="
|
||||
cd /opt/dobotai/container
|
||||
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
|
||||
|
||||
echo "== readback =="
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:8090/healthz; echo
|
||||
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
|
||||
for lit in sys_full_json_obj autoLayout _CARD_DEBUG_TOOL_ENTRY; do
|
||||
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
|
||||
echo "literal $lit: PRESENT"
|
||||
else
|
||||
echo "literal $lit: ABSENT"
|
||||
fi
|
||||
done
|
||||
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -5
|
||||
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
|
||||
@@ -21,6 +21,24 @@ const (
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
|
||||
|
||||
// cardWideScreenJSONKey is DingTalk's reserved system field inside
|
||||
// cardData.cardParamMap. Template variables whose value is a complex type
|
||||
// (object / array of objects) must be passed through this field as a JSON
|
||||
// string; plain string/number/boolean variables go in cardParamMap
|
||||
// directly. Putting a complex value anywhere else (for example as a
|
||||
// sibling of cardParamMap under cardData) leaves the template variable
|
||||
// unassigned and silently falls back to the default layout.
|
||||
cardWideScreenJSONKey = "sys_full_json_obj"
|
||||
|
||||
// cardWideScreenJSON configures the AI card's root "config": PC clients
|
||||
// render the card wide (auto layout) when autoLayout is true. The card
|
||||
// template must declare an object variable "config" with a boolean child
|
||||
// "autoLayout" (card builder > 数据源 > 编辑变量) and the value must be
|
||||
// passed when the card is created - setting it statically inside the
|
||||
// template does not take effect. Mobile clients keep their own adaptive
|
||||
// layout, so this only widens the card on desktop.
|
||||
cardWideScreenJSON = `{"config":{"autoLayout":true}}`
|
||||
)
|
||||
|
||||
// CardRequest is the input for sending a final AI card.
|
||||
@@ -229,6 +247,7 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
|
||||
"feedbackReasonSelected": "",
|
||||
"showFeedbackReasons": "false",
|
||||
"feedbackMessage": "",
|
||||
cardWideScreenJSONKey: cardWideScreenJSON,
|
||||
},
|
||||
},
|
||||
"userIdType": 1,
|
||||
|
||||
@@ -1289,6 +1289,117 @@ func TestOAuthAcrossStartAndUpdates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: PC wide-screen layout config is delivered through the reserved
|
||||
// cardParamMap system field, never as a sibling of cardParamMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCardWideScreenAutoLayoutParam(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req CardRequest
|
||||
}{
|
||||
{
|
||||
name: "direct",
|
||||
req: CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-456",
|
||||
Content: "Hello from AI",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group",
|
||||
req: CardRequest{
|
||||
ConversationType: core.ConversationGroup,
|
||||
ExternalChatID: "chat-789",
|
||||
ExternalUserID: "user-456",
|
||||
Content: "Hello from AI",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sender.StartCard(context.Background(), tc.req); err != nil {
|
||||
t.Fatalf("StartCard: %v", err)
|
||||
}
|
||||
|
||||
cardData, ok := ts.lastCreateBody["cardData"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("cardData missing or wrong type: %#v", ts.lastCreateBody["cardData"])
|
||||
}
|
||||
// Regression guard: a complex-typed value placed next to
|
||||
// cardParamMap leaves the template variable unassigned and the
|
||||
// card silently renders in the default narrow layout.
|
||||
for _, badKey := range []string{"config", cardWideScreenJSONKey} {
|
||||
if _, present := cardData[badKey]; present {
|
||||
t.Fatalf("%q must not be a sibling of cardParamMap: %#v", badKey, cardData)
|
||||
}
|
||||
}
|
||||
paramMap, ok := cardData["cardParamMap"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("cardParamMap missing or wrong type: %#v", cardData["cardParamMap"])
|
||||
}
|
||||
raw, ok := paramMap[cardWideScreenJSONKey].(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s must be a JSON string, got %#v", cardWideScreenJSONKey, paramMap[cardWideScreenJSONKey])
|
||||
}
|
||||
var decoded struct {
|
||||
Config struct {
|
||||
AutoLayout bool `json:"autoLayout"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
t.Fatalf("%s is not valid JSON (%q): %v", cardWideScreenJSONKey, raw, err)
|
||||
}
|
||||
if !decoded.Config.AutoLayout {
|
||||
t.Fatalf("config.autoLayout=false in %q, want true", raw)
|
||||
}
|
||||
// Plain string variables must keep going through cardParamMap directly.
|
||||
if paramMap["content"] != tc.req.Content {
|
||||
t.Errorf("content=%v, want %v", paramMap["content"], tc.req.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: streaming updates only carry content (no layout config)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCardWideScreenNotSentOnStreamingUpdate(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outTrackID, err := sender.StartCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-456",
|
||||
Content: "placeholder",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartCard: %v", err)
|
||||
}
|
||||
if err := sender.UpdateCard(context.Background(), outTrackID, "streamed chunk", false, false); err != nil {
|
||||
t.Fatalf("UpdateCard: %v", err)
|
||||
}
|
||||
if _, present := ts.lastStreamBody[cardWideScreenJSONKey]; present {
|
||||
t.Fatalf("streaming body must not carry %s: %#v", cardWideScreenJSONKey, ts.lastStreamBody)
|
||||
}
|
||||
if ts.lastStreamBody["key"] != "content" {
|
||||
t.Errorf("stream key=%v, want content", ts.lastStreamBody["key"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user