feat(knowledge): add short-term ask memory

This commit is contained in:
2026-07-18 21:22:49 +08:00
parent 2eda757483
commit 064816a784
33 changed files with 1348 additions and 99 deletions
+2 -1
View File
@@ -131,6 +131,7 @@ FROM (
UNION ALL SELECT 'aihr_knowledge_app'
UNION ALL SELECT 'aihr_knowledge_app_space'
UNION ALL SELECT 'aihr_knowledge_query_log'
UNION ALL SELECT 'aihr_knowledge_conversation'
UNION ALL SELECT 'aihr_knowledge_admin_audit'
UNION ALL SELECT 'aihr_learning_question'
UNION ALL SELECT 'aihr_practice_question_feedback'
@@ -162,7 +163,7 @@ REMOTE
)" || fail "remote schema check failed: $remote_ssh:$remote_db"
[[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')"
echo "remote_schema=34/34 $remote_ssh:$remote_db"
echo "remote_schema=35/35 $remote_ssh:$remote_db"
}
changed_files="$(git status --porcelain)"
+2 -1
View File
@@ -27,6 +27,7 @@ CREATE TABLE aihr_knowledge_app (tenant_id varchar(20), label varchar(20)) ENGIN
CREATE TABLE aihr_knowledge_app_space (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB;
CREATE TABLE aihr_knowledge_query_log (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB;
CREATE TABLE aihr_knowledge_admin_audit (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB;
CREATE TABLE aihr_knowledge_conversation (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB;
CREATE TABLE aihr_learning_question (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB;
CREATE TABLE aihr_practice_question_feedback (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB;
CREATE TABLE aihr_onboard_exam (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB;
@@ -53,6 +54,6 @@ aligned_columns="$(mysql "$DB" -N -B -e "
AND column_name IN ('tenant_id', 'label')
AND collation_name='utf8mb4_general_ci';
")"
test "$aligned_columns" = '36'
test "$aligned_columns" = '38'
echo "PASS: AIHR release tables inherit the target database collation and the migration is idempotent"
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
const baseUrl = process.env.AIHR_BASE_URL || 'http://127.0.0.1:8080';
const phone = process.env.AIHR_VERIFY_PHONE || '13900000001';
const smsCode = process.env.AIHR_VERIFY_SMS_CODE || '123456';
const clientId = '428a8310cd442757ae699df5d894f051';
async function request(endpoint, { method = 'GET', body, token } = {}) {
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers: {
clientid: clientId,
'Content-Language': 'zh_CN',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body ? { 'Content-Type': 'application/json;charset=utf-8' } : {})
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000)
});
const text = await response.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
throw new Error(`${endpoint} 返回了非 JSON 响应(HTTP ${response.status})`);
}
if (!response.ok || Number(payload.code) !== 200) {
throw new Error(`${endpoint} 失败:HTTP ${response.status} / ${payload.code} ${payload.msg || ''}`);
}
return payload.data;
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
await request(`/resource/sms/code?phonenumber=${encodeURIComponent(phone)}`);
const login = await request('/auth/mobile/sms-login', {
method: 'POST',
body: { phonenumber: phone, smsCode, tenantId: '000000' }
});
const token = login?.access_token;
assert(token, '手机号登录未返回 access_token');
const conversationId = crypto.randomUUID();
const first = await request('/api/knowledge/query', {
method: 'POST',
token,
body: {
queryText: '报修受理后第一步应该做什么?',
category: 'sop',
position: '生活顾问',
limit: 5,
conversationId,
contextVersion: 0
}
});
assert(first?.conversationId === conversationId, '首问没有返回同一 conversationId');
assert(Number(first?.contextVersion) === 1, '首问 contextVersion 未从 0 递增到 1');
assert(first?.intent === 'QA', `首问意图错误:${first?.intent}`);
assert(!first?.noEvidence, '首问未召回已授权的报修依据');
const followup = await request('/api/knowledge/query', {
method: 'POST',
token,
body: {
queryText: '把对应原文件给我',
category: 'sop',
position: '生活顾问',
limit: 5,
conversationId,
contextVersion: first.contextVersion
}
});
assert(followup?.conversationId === conversationId, '追问没有保持同一 conversationId');
assert(Number(followup?.contextVersion) === 2, '追问 contextVersion 未从 1 递增到 2');
assert(followup?.intent === 'FILE', `追问意图错误:${followup?.intent}`);
assert(String(followup?.rewrittenQuery || '').includes('报修'), '追问没有结合首问主题重写');
assert(Array.isArray(followup?.resources) && followup.resources.length > 0, '追问未返回授权原文件');
const resource = followup.resources[0];
assert(resource?.contentUrl?.startsWith('/api/knowledge/resources/'), '原文件没有使用受保护内容地址');
const download = await fetch(`${baseUrl}${resource.contentUrl}`, {
headers: { clientid: clientId, Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000)
});
assert(download.ok, `原文件下载失败:HTTP ${download.status}`);
assert((await download.arrayBuffer()).byteLength > 0, '原文件下载内容为空');
const videoFollowup = await request('/api/knowledge/query', {
method: 'POST',
token,
body: {
queryText: '有对应操作视频吗?',
category: 'sop',
position: '生活顾问',
limit: 5,
conversationId,
contextVersion: followup.contextVersion
}
});
assert(Number(videoFollowup?.contextVersion) === 3, '视频追问 contextVersion 未递增到 3');
assert(videoFollowup?.intent === 'VIDEO', `视频追问意图错误:${videoFollowup?.intent}`);
assert(Array.isArray(videoFollowup?.resources) && videoFollowup.resources.length === 0, '无视频时返回了伪造资源');
assert(String(videoFollowup?.answer || '').includes('未找到'), '无视频时没有明确说明未找到');
const staleResponse = await fetch(`${baseUrl}/api/knowledge/query`, {
method: 'POST',
headers: {
clientid: clientId,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify({
queryText: '再说一遍',
category: 'sop',
conversationId,
contextVersion: first.contextVersion
}),
signal: AbortSignal.timeout(30_000)
});
const stalePayload = await staleResponse.json();
assert(Number(stalePayload.code) === 409, `旧上下文版本未被拒绝:${stalePayload.code}`);
console.log('PASS 手机号真实登录');
console.log('PASS 首问召回有依据答案,contextVersion=1');
console.log(`PASS 追问识别 FILE 并重写为:${followup.rewrittenQuery}`);
console.log(`PASS 授权原文件下载:${resource.title}`);
console.log('PASS 视频追问无匹配时明确返回空资源');
console.log('PASS 旧 contextVersion 返回 409');