fix:7.22问答调整
This commit is contained in:
parent
69a0992e69
commit
1b788f1a5c
|
|
@ -24,7 +24,7 @@ from app.schemas.chat import (
|
|||
MessageResponse,
|
||||
)
|
||||
from app.services.audit_service import write_audit
|
||||
from app.services.document_service import resolve_chat_doc_scope
|
||||
from app.services.document_service import build_unit_chain, resolve_chat_doc_scope
|
||||
from app.services.user_service import JOB_TITLE_GROUP_ALIASES
|
||||
|
||||
|
||||
|
|
@ -178,7 +178,16 @@ def stream_chat(
|
|||
is_admin = current_user.is_superuser or any(
|
||||
role.code in {"admin", "system_admin"} for role in current_user.roles
|
||||
)
|
||||
allowed_tags = [] if current_user.is_superuser else [role.code for role in current_user.roles]
|
||||
# ADP 检索层过滤:角色码 + 提问人 unit_chain(整条向上链,支持就近优先/上级回退) + 公开范围标签.
|
||||
# 只传角色码时,其他单位的专属文档也会被 ADP 一并召回喂给 LLM,LLM 会在正文写"依据文档:《XX 单位规定》";
|
||||
# 把整条链一起下发,是为了让本单位无对应制度时,上级单位的制度也在候选内,由 LLM 按"就近优先"选用.
|
||||
# 兄弟单位(不在向上链条上的)始终不进 allowed_tags,不会被召回.超管不过滤.
|
||||
if current_user.is_superuser:
|
||||
allowed_tags: list[str] = []
|
||||
else:
|
||||
allowed_tags = [role.code for role in current_user.roles]
|
||||
allowed_tags.extend(build_unit_chain(current_user))
|
||||
allowed_tags.extend(["所有人可见", "通用", "全体员工"])
|
||||
|
||||
# role_hint 必须是业务岗位(如"总院中层副职""员工"),用来匹配制度里按职级分组的条款。
|
||||
# 只取 User.job_title;权限角色(employee/admin/system_admin)和 is_superuser 不参与,
|
||||
|
|
@ -286,6 +295,21 @@ def stream_chat(
|
|||
f"【提问人所属单位:{own_unit}】"
|
||||
f"当前单位链上均无对应制度,仅可引用公开制度作答,若信息不足请明确说明。"
|
||||
)
|
||||
# 硬约束:兜底 ADP 检索层过滤失效 -> 强制 LLM 拒用 unit_chain 之外单位的专属文档.
|
||||
# 允许 unit_chain 内的上级单位文档(就近优先由上面 fell_back/chosen_unit 分支引导).
|
||||
# 优先级最高,凌驾于 FAQ 命中等所有规则.命中 FAQ 但 FAQ 内容显然只覆盖链外单位时,也必须拒答.
|
||||
hint_lines.append(
|
||||
f"【单位隔离硬约束·最高优先级】提问人所属单位:{own_unit}。"
|
||||
f"允许引用的文档必须满足以下之一:"
|
||||
f"(a) 标签值等于{own_unit};"
|
||||
f"(b) 标签值为{own_unit}的上级单位(逐级向上);"
|
||||
f"(c) 标签值为'通用/全体员工/所有人可见'等公开范围。"
|
||||
f"不满足则:"
|
||||
f"(1) 禁止将其内容用于作答,禁止在'依据文档'中列出;"
|
||||
f"(2) 若召回文档全部不满足(即本单位及其上级链上均无对应制度),必须回复原文:"
|
||||
f"'知识库中未找到 {own_unit} 及其上级单位对应的规定,建议咨询本单位财务部门。';"
|
||||
f"(3) 此规则覆盖'FAQ 命中即返回标准答案'的短路,即使命中 FAQ 也须先判定单位适用性。"
|
||||
)
|
||||
if hint_lines:
|
||||
adp_question = (
|
||||
f"{raw_question}\n\n"
|
||||
|
|
|
|||
|
|
@ -835,6 +835,33 @@ button {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 共享文档 (ref.source_url 存在) 时, 标题变成蓝色外链, 悬停加下划线 */
|
||||
.bubble-reference-item summary .ref-title-link {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bubble-reference-item summary .ref-title-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 展开区域内的链接地址行, 显示完整 URL, 蓝色字, 可换行 */
|
||||
.bubble-reference-item .ref-url-line {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: #1a73e8;
|
||||
background: rgba(26, 115, 232, 0.06);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bubble-reference-item .ref-url-line:hover {
|
||||
background: rgba(26, 115, 232, 0.16);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bubble-reference-item summary .ref-page {
|
||||
color: var(--muted);
|
||||
margin-left: 2px;
|
||||
|
|
|
|||
|
|
@ -308,10 +308,26 @@ watch(messages, scrollToEnd, { deep: true })
|
|||
>
|
||||
<summary>
|
||||
<span class="ref-icon">📄</span>
|
||||
<span class="ref-title">{{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }}</span>
|
||||
<a
|
||||
v-if="ref.source_url"
|
||||
:href="ref.source_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ref-title ref-title-link"
|
||||
@click.stop
|
||||
>{{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }}</a>
|
||||
<span v-else class="ref-title">{{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }}</span>
|
||||
<small v-if="ref.page_no" class="ref-page">第 {{ ref.page_no }} 页</small>
|
||||
<span class="ref-arrow">›</span>
|
||||
</summary>
|
||||
<a
|
||||
v-if="ref.source_url"
|
||||
:href="ref.source_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ref-url-line"
|
||||
@click.stop
|
||||
>🔗 {{ ref.source_url }}</a>
|
||||
<div v-if="ref.snippet" class="ref-snippet">{{ ref.snippet.trim() }}</div>
|
||||
</details>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { request, uploadFile, streamRequest } = require('../../utils/request')
|
||||
const { splitMarkdownImages } = require('../../utils/parse-image')
|
||||
|
||||
const app = getApp()
|
||||
|
||||
|
|
@ -88,7 +89,11 @@ Page({
|
|||
const list = this.data.messages.slice()
|
||||
const i = list.findIndex((m) => m.key === key)
|
||||
if (i === -1) return
|
||||
list[i] = Object.assign({}, list[i], patch)
|
||||
const merged = Object.assign({}, list[i], patch)
|
||||
if (patch && typeof patch.text === 'string') {
|
||||
merged.contentSegments = splitMarkdownImages(merged.text)
|
||||
}
|
||||
list[i] = merged
|
||||
this.setData({ messages: list, scrollAnchor: 'anchor-end' })
|
||||
},
|
||||
|
||||
|
|
@ -96,7 +101,11 @@ Page({
|
|||
const list = this.data.messages.slice()
|
||||
const i = list.findIndex((m) => m.key === key)
|
||||
if (i === -1) return
|
||||
list[i] = Object.assign({}, list[i], { text: (list[i].text || '') + delta })
|
||||
const newText = (list[i].text || '') + delta
|
||||
list[i] = Object.assign({}, list[i], {
|
||||
text: newText,
|
||||
contentSegments: splitMarkdownImages(newText)
|
||||
})
|
||||
this.setData({ messages: list, scrollAnchor: 'anchor-end' })
|
||||
},
|
||||
|
||||
|
|
@ -188,10 +197,12 @@ Page({
|
|||
}
|
||||
if (m.answer || (m.references && m.references.length)) {
|
||||
this._msgSeq += 1
|
||||
const ansText = m.answer || ''
|
||||
msgs.push({
|
||||
key: 'k' + this._msgSeq,
|
||||
role: 'assistant',
|
||||
text: m.answer || '',
|
||||
text: ansText,
|
||||
contentSegments: splitMarkdownImages(ansText),
|
||||
references: m.references || [],
|
||||
streaming: false
|
||||
})
|
||||
|
|
@ -256,6 +267,115 @@ Page({
|
|||
}
|
||||
},
|
||||
|
||||
// 参考文档带 source_url 时(共享文档)点击复制链接到剪贴板。
|
||||
// 小程序无法直接打开任意外链, 复制后由用户粘贴到浏览器。
|
||||
onRefLinkTap(e) {
|
||||
const url = e.currentTarget.dataset.url
|
||||
if (!url) return
|
||||
wx.setClipboardData({
|
||||
data: url,
|
||||
success: () => wx.showToast({ title: '链接已复制', icon: 'success' }),
|
||||
fail: () => wx.showToast({ title: '复制失败', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
// 长按聊天记录里的图片 (自己发的 or AI 回答里的 markdown 图片) -> 弹菜单
|
||||
onImageLongPress(e) {
|
||||
const src = e.currentTarget.dataset.src
|
||||
if (!src) return
|
||||
wx.showActionSheet({
|
||||
itemList: ['预览大图', '引用此图追问', '保存到相册'],
|
||||
success: (r) => {
|
||||
if (r.tapIndex === 0) wx.previewImage({ current: src, urls: [src] })
|
||||
else if (r.tapIndex === 1) this._quoteImage(src)
|
||||
else if (r.tapIndex === 2) this._saveImageToAlbum(src)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async _quoteImage(src) {
|
||||
if (this.data.streaming) {
|
||||
wx.showToast({ title: '当前对话生成中', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this.data.pendingImages.length >= 4) {
|
||||
wx.showToast({ title: '最多 4 张图', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const localPath = await this._materializeToLocal(src)
|
||||
if (!localPath) return
|
||||
await this._uploadTempImage({ tempFilePath: localPath })
|
||||
},
|
||||
|
||||
async _saveImageToAlbum(src) {
|
||||
const path = await this._materializeToLocal(src)
|
||||
if (!path) return
|
||||
wx.saveImageToPhotosAlbum({
|
||||
filePath: path,
|
||||
success: () => wx.showToast({ title: '已保存到相册' }),
|
||||
fail: (err) => wx.showToast({ title: '保存失败:' + (err.errMsg || ''), icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
// 把任意来源 (远端 URL / data URL / 本地临时路径) 归一化成本地文件路径,
|
||||
// 后续能直接喂给 wx.uploadFile / wx.saveImageToPhotosAlbum。失败时返回 null 并已给用户提示。
|
||||
_materializeToLocal(src) {
|
||||
if (!src) return Promise.resolve(null)
|
||||
if (src.indexOf('data:') === 0) return this._dataUrlToTempFile(src)
|
||||
if (/^https?:\/\//.test(src)) return this._downloadRemote(src)
|
||||
return Promise.resolve(src)
|
||||
},
|
||||
|
||||
_dataUrlToTempFile(dataUrl) {
|
||||
return new Promise((resolve) => {
|
||||
const m = /^data:([^;]+);base64,(.+)$/.exec(dataUrl)
|
||||
if (!m) {
|
||||
wx.showToast({ title: '图片格式无法识别', icon: 'none' })
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png'
|
||||
const path = `${wx.env.USER_DATA_PATH}/quote_${Date.now()}.${ext}`
|
||||
wx.getFileSystemManager().writeFile({
|
||||
filePath: path,
|
||||
data: m[2],
|
||||
encoding: 'base64',
|
||||
success: () => resolve(path),
|
||||
fail: (err) => {
|
||||
wx.showToast({ title: '写临时文件失败: ' + (err.errMsg || ''), icon: 'none' })
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
_downloadRemote(url) {
|
||||
wx.showLoading({ title: '拉取图片...', mask: true })
|
||||
return new Promise((resolve) => {
|
||||
wx.downloadFile({
|
||||
url,
|
||||
success: (r) => {
|
||||
wx.hideLoading()
|
||||
if (r.statusCode === 200) {
|
||||
resolve(r.tempFilePath)
|
||||
} else {
|
||||
wx.showToast({ title: '下载失败 HTTP ' + r.statusCode, icon: 'none' })
|
||||
resolve(null)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
wx.hideLoading()
|
||||
wx.showModal({
|
||||
title: '拉图失败',
|
||||
content: (err.errMsg || '') + '\n\n若提示"域名不在合法列表",请在微信公众平台 -> 开发 -> 服务器域名 里把该图片来源域名加进 downloadFile 合法域名。',
|
||||
showCancel: false
|
||||
})
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
// ---------------- 语音 ----------------
|
||||
toggleVoiceMode() {
|
||||
this.setData({ voiceMode: !this.data.voiceMode })
|
||||
|
|
|
|||
|
|
@ -43,20 +43,47 @@
|
|||
src="{{att.data_url}}"
|
||||
mode="aspectFill"
|
||||
bindtap="previewImage"
|
||||
bindlongpress="onImageLongPress"
|
||||
data-src="{{att.data_url}}"
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
<text wx:if="{{item.text}}" class="txt">{{item.text}}</text>
|
||||
<text wx:if="{{item.text}}" class="txt" user-select="{{true}}">{{item.text}}</text>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<text class="txt">{{item.text || (item.streaming ? '思考中...' : '')}}</text>
|
||||
<block wx:if="{{!item.text && item.streaming}}">
|
||||
<text class="txt">思考中...</text>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<block wx:for="{{item.contentSegments}}" wx:key="idx" wx:for-item="seg" wx:for-index="idx">
|
||||
<text wx:if="{{seg.type === 'text'}}" class="txt" user-select="{{true}}">{{seg.text}}</text>
|
||||
<image
|
||||
wx:elif="{{seg.type === 'img'}}"
|
||||
class="ai-img"
|
||||
src="{{seg.src}}"
|
||||
mode="widthFix"
|
||||
bindtap="previewImage"
|
||||
bindlongpress="onImageLongPress"
|
||||
data-src="{{seg.src}}"
|
||||
/>
|
||||
</block>
|
||||
</block>
|
||||
<block wx:if="{{item.references && item.references.length}}">
|
||||
<view class="refs">
|
||||
<view class="refs-title">参考来源</view>
|
||||
<view class="ref-item" wx:for="{{item.references}}" wx:for-item="ref" wx:key="idx" wx:for-index="idx">
|
||||
<text class="ref-title">[{{idx + 1}}] {{ref.source_title}}</text>
|
||||
<text wx:if="{{ref.snippet}}" class="ref-snippet">{{ref.snippet}}</text>
|
||||
<text class="ref-title" user-select="{{true}}">[{{idx + 1}}] {{ref.source_title}}</text>
|
||||
<view
|
||||
wx:if="{{ref.source_url}}"
|
||||
class="ref-link"
|
||||
hover-class="ref-link-hover"
|
||||
bindtap="onRefLinkTap"
|
||||
data-url="{{ref.source_url}}"
|
||||
>
|
||||
<text class="ref-link-icon">🔗</text>
|
||||
<text class="ref-link-url" user-select="{{true}}">{{ref.source_url}}</text>
|
||||
</view>
|
||||
<text wx:if="{{ref.snippet}}" class="ref-snippet" user-select="{{true}}">{{ref.snippet}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
|
|
|||
|
|
@ -186,6 +186,16 @@
|
|||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* AI 回答里嵌入的 markdown 图片, 宽度撑满气泡, 高度按图片原比例 */
|
||||
.ai-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 520rpx;
|
||||
margin: 12rpx 0;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.refs {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 16rpx;
|
||||
|
|
@ -206,6 +216,30 @@
|
|||
color: var(--brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ref-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 6rpx;
|
||||
align-self: stretch;
|
||||
font-size: 22rpx;
|
||||
margin-top: 4rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
background: rgba(26, 115, 232, 0.06);
|
||||
}
|
||||
.ref-link-icon {
|
||||
color: #1a73e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ref-link-url {
|
||||
color: #1a73e8;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ref-link-hover {
|
||||
background: rgba(26, 115, 232, 0.16);
|
||||
}
|
||||
.ref-snippet {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
// 把 AI 回答里的 markdown 图片语法  从纯文本里切出来,
|
||||
// 让 chat.wxml 能把图片用真正的 <image> 组件渲染 (可长按引用),
|
||||
// 其余文本仍走 <text> 保持现状。
|
||||
// 只识别标准 markdown image, 不解析加粗/列表等其他语法。
|
||||
const IMG_RE = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g
|
||||
|
||||
function splitMarkdownImages(text) {
|
||||
const src = String(text || '')
|
||||
if (!src) return []
|
||||
const out = []
|
||||
let cursor = 0
|
||||
IMG_RE.lastIndex = 0
|
||||
let m
|
||||
while ((m = IMG_RE.exec(src)) !== null) {
|
||||
if (m.index > cursor) {
|
||||
out.push({ type: 'text', text: src.slice(cursor, m.index) })
|
||||
}
|
||||
out.push({ type: 'img', src: m[1] })
|
||||
cursor = IMG_RE.lastIndex
|
||||
}
|
||||
if (cursor < src.length) {
|
||||
out.push({ type: 'text', text: src.slice(cursor) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { splitMarkdownImages }
|
||||
Loading…
Reference in New Issue