From fed32939e62153ccbbb8c8c8cec59eea7720ed36 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 12 Jul 2026 16:35:06 +0800 Subject: [PATCH] fix(personal): verify per-source citations and cleanup --- scripts/personal-assistant-smoke.sh | 95 ++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/scripts/personal-assistant-smoke.sh b/scripts/personal-assistant-smoke.sh index 6967ef12..a28f1140 100755 --- a/scripts/personal-assistant-smoke.sh +++ b/scripts/personal-assistant-smoke.sh @@ -244,6 +244,67 @@ assert_search_hit() { || fail "$label search did not return item=$item_id type=$expected_type query=$query hits=$(jq -c '.data.hits // []' <<<"$HTTP_BODY")" } +assert_single_item_ask() { + local item_id="$1" query="$2" expected_type="$3" label="$4" session_json=null response_session + [[ "$SESSION_ID" =~ ^[0-9]+$ ]] && session_json="$SESSION_ID" + request POST /api/aihr/personal-assistant/ask "$TOKEN_A" "$CLIENT_A" \ + "$(jq -cn --arg query "$query" --argjson item "$item_id" --argjson session "$session_json" \ + '{sessionId:$session,queryText:$query,scope:["PERSONAL"],itemIds:[$item]}')" + expect_success "A $label single-item answer" + response_session="$(jq -er '.data.sessionId | tostring' <<<"$HTTP_BODY")" + [[ "$response_session" =~ ^[0-9]+$ ]] || fail "A $label answer did not persist session" + if [[ "$SESSION_ID" =~ ^[0-9]+$ ]]; then + expect_code "$response_session" "$SESSION_ID" "A $label answer session continuity" + else + SESSION_ID="$response_session" + fi + [[ "$(jq --arg item "$item_id" --arg type "$expected_type" \ + '[.data.citations[] | select(.domain=="PERSONAL" and (.itemId|tostring)==$item and .sourceType==$type)]|length' \ + <<<"$HTTP_BODY")" -ge 1 ]] \ + || fail "A $label answer lacks exact PERSONAL/$expected_type citation item=$item_id citations=$(jq -c '.data.citations // []' <<<"$HTTP_BODY")" + expect_code "$(jq --arg item "$item_id" --arg type "$expected_type" \ + '[.data.citations[] | select(.domain!="PERSONAL" or (.itemId|tostring)!=$item or .sourceType!=$type)]|length' \ + <<<"$HTTP_BODY")" 0 "A $label answer foreign citation count" +} + +qdrant_item_count() { + local item_id="$1" collection_exists count=0 + collection_exists="$(curl -sS "$QDRANT_URL/collections" \ + | jq -er --arg name "$QDRANT_COLLECTION" '[.result.collections[] | select(.name==$name)]|length')" \ + || fail "Qdrant collection discovery failed" + if [[ "$collection_exists" -ge 1 ]]; then + count="$(curl -sS -X POST "$QDRANT_URL/collections/$QDRANT_COLLECTION/points/count" \ + -H 'Content-Type: application/json' \ + --data "{\"exact\":true,\"filter\":{\"must\":[{\"key\":\"tenant_id\",\"match\":{\"value\":\"000000\"}},{\"key\":\"owner_user_id\",\"match\":{\"value\":$USER_A}},{\"key\":\"item_id\",\"match\":{\"value\":$item_id}}]}}" \ + | jq -er '.result.count')" || fail "Qdrant residual check failed item=$item_id" + fi + printf '%s' "$count" +} + +assert_business_cleanup() { + local ids="$TEXT_ITEM_ID,$PDF_ITEM_ID,$URL_ITEM_ID" index + expect_code "$(mysql "select count(*) from aihr_personal_cleanup_job + where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($ids) and status='DONE'")" \ + 3 "application cleanup jobs DONE" + expect_code "$(mysql "select count(*) from aihr_personal_item + where tenant_id='000000' and owner_user_id=$USER_A and id in ($ids) and status='DELETED'")" \ + 3 "application item tombstones DELETED" + expect_code "$(mysql "select count(*) from aihr_personal_fragment + where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($ids)")" \ + 0 "application fragment residual" + for index in "${!OSS_IDS[@]}"; do + expect_code "$(mysql "select count(*) from sys_oss where oss_id=${OSS_IDS[$index]}")" \ + 0 "application OSS row ${OSS_IDS[$index]} residual" + if docker run --rm --network "$DOCKER_NETWORK" \ + -e 'MC_HOST_local=http://ruoyi:ruoyi123@wygj-minio:9000' minio/mc \ + stat "local/ruoyi-personal/${OBJECT_KEYS[$index]}" >/dev/null 2>&1; then + fail "application MinIO object ${OBJECT_KEYS[$index]} residual" + fi + expect_code "$(qdrant_item_count "${ITEM_IDS[$index]}")" 0 \ + "application Qdrant points item=${ITEM_IDS[$index]} residual" + done +} + assert_b_isolation() { local item_id="$1" label="$2" operation for operation in detail download retry delete; do @@ -335,17 +396,9 @@ assert_search_hit "$TEXT_ITEM_ID" "$TEXT_QUERY" TEXT text assert_search_hit "$PDF_ITEM_ID" "$PDF_QUERY" FILE PDF assert_search_hit "$URL_ITEM_ID" "$PUBLIC_QUERY" URL URL -ALL_ITEM_IDS_JSON="$(printf '%s\n' "$TEXT_ITEM_ID" "$PDF_ITEM_ID" "$URL_ITEM_ID" | jq -s 'map(tonumber)')" -request POST /api/aihr/personal-assistant/ask "$TOKEN_A" "$CLIENT_A" \ - "$(jq -cn --arg query "$TEXT_QUERY $PDF_QUERY $PUBLIC_QUERY $RUN_ID" --argjson ids "$ALL_ITEM_IDS_JSON" \ - '{queryText:$query,scope:["PERSONAL"],itemIds:$ids}')" -expect_success "A personal answer" -SESSION_ID="$(jq -er '.data.sessionId | tostring' <<<"$HTTP_BODY")" -[[ "$SESSION_ID" =~ ^[0-9]+$ ]] || fail "A answer did not persist session" -[[ "$(jq '[.data.citations[] | select(.sourceType=="TEXT")]|length' <<<"$HTTP_BODY")" -ge 1 ]] \ - || fail "A answer citations did not include the selected TEXT item" -[[ "$(jq '[.data.citations[] | select(.sourceType=="FILE" or .sourceType=="URL")]|length' <<<"$HTTP_BODY")" -ge 1 ]] \ - || fail "A answer citations did not include the selected PDF/URL item" +assert_single_item_ask "$TEXT_ITEM_ID" "$TEXT_QUERY" TEXT text +assert_single_item_ask "$PDF_ITEM_ID" "$PDF_QUERY" FILE PDF +assert_single_item_ask "$URL_ITEM_ID" "$PUBLIC_QUERY" URL URL request GET "/api/aihr/personal-assistant/sessions/$SESSION_ID" "$TOKEN_B" "$CLIENT_B" expect_error PERSONAL_SESSION_NOT_FOUND "B session isolation" @@ -379,27 +432,9 @@ for _ in {1..75}; do done expect_code "$cleanup_done" 3 "three cleanup jobs completed" +assert_business_cleanup cleanup_once expect_code "$(mysql "select count(*) from aihr_personal_item where tenant_id='000000' and owner_user_id=$USER_A and title like '$TITLE-%'")" 0 "run items residual" expect_code "$(mysql "select count(*) from aihr_personal_fragment where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($TEXT_ITEM_ID,$PDF_ITEM_ID,$URL_ITEM_ID)")" 0 "run fragments residual" expect_code "$(mysql "select count(*) from aihr_personal_cleanup_job where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($TEXT_ITEM_ID,$PDF_ITEM_ID,$URL_ITEM_ID)")" 0 "run cleanup jobs residual" -qdrant_collection_exists="$(curl -sS "$QDRANT_URL/collections" \ - | jq -er --arg name "$QDRANT_COLLECTION" '[.result.collections[] | select(.name==$name)]|length')" \ - || fail "Qdrant collection discovery failed" -for index in "${!OSS_IDS[@]}"; do - expect_code "$(mysql "select count(*) from sys_oss where oss_id=${OSS_IDS[$index]}")" 0 "run OSS row ${OSS_IDS[$index]} residual" - if docker run --rm --network "$DOCKER_NETWORK" \ - -e 'MC_HOST_local=http://ruoyi:ruoyi123@wygj-minio:9000' minio/mc \ - stat "local/ruoyi-personal/${OBJECT_KEYS[$index]}" >/dev/null 2>&1; then - fail "run MinIO object ${OBJECT_KEYS[$index]} residual" - fi - qdrant_count=0 - if [[ "$qdrant_collection_exists" -ge 1 ]]; then - qdrant_count="$(curl -sS -X POST "$QDRANT_URL/collections/$QDRANT_COLLECTION/points/count" \ - -H 'Content-Type: application/json' \ - --data "{\"exact\":true,\"filter\":{\"must\":[{\"key\":\"tenant_id\",\"match\":{\"value\":\"000000\"}},{\"key\":\"owner_user_id\",\"match\":{\"value\":$USER_A}},{\"key\":\"item_id\",\"match\":{\"value\":${ITEM_IDS[$index]}}}]}}" \ - | jq -er '.result.count')" || fail "Qdrant residual check failed item=${ITEM_IDS[$index]}" - fi - expect_code "$qdrant_count" 0 "run Qdrant points item=${ITEM_IDS[$index]} residual" -done echo "PASS: personal assistant TEXT/PDF/public URL isolation, retrieval, privacy and cleanup gates run=$RUN_ID"