Browse Source

all

hanyuqing
hanyuqing 3 months ago
parent
commit
bd76e5faf9
  1. 51
      controller/QAController.py
  2. 81
      vue/src/system/GraphBuilder.vue
  3. 296
      vue/src/system/GraphQA.vue

51
controller/QAController.py

@ -1,5 +1,7 @@
import json import json
import traceback import traceback
import uuid
from util.neo4j_utils import Neo4jUtil, neo4j_client from util.neo4j_utils import Neo4jUtil, neo4j_client
import httpx import httpx
from robyn import jsonify, Response from robyn import jsonify, Response
@ -7,7 +9,48 @@ from robyn import jsonify, Response
from app import app from app import app
from controller.client import client from controller.client import client
def convert_to_g6_format(data):
entities = data["entities"]
relations = data["relations"]
# 创建实体名称到唯一ID的映射
name_to_id = {}
nodes = []
for ent in entities:
name = ent["n"]
if name not in name_to_id:
node_id = str(uuid.uuid4())
name_to_id[name] = node_id
nodes.append({
"id": node_id,
"label": name,
"type": ent["t"] # 可用于 G6 的节点样式区分
})
# 构建边
edges = []
for rel in relations:
e1 = rel["e1"]
e2 = rel["e2"]
r = rel["r"]
source_id = name_to_id.get(e1)
target_id = name_to_id.get(e2)
if source_id and target_id:
edges.append({
"source": source_id,
"target": target_id,
"label": r
})
else:
print(f"Warning: Entity not found for relation: {rel}")
return {
"nodes": nodes,
"edges": edges
}
@app.post("/api/qa/analyze") @app.post("/api/qa/analyze")
async def analyze(request): async def analyze(request):
body = request.json() body = request.json()
@ -56,7 +99,7 @@ async def analyze(request):
sorted_items = sorted(items, key=lambda x: x["sort"], reverse=True) sorted_items = sorted(items, key=lambda x: x["sort"], reverse=True)
# 第四步:取前5个 # 第四步:取前5个
top5 = sorted_items[:1] top5 = sorted_items[:5]
for item in top5: for item in top5:
resp = await client.post( resp = await client.post(
"/extract_entities_and_relations", "/extract_entities_and_relations",
@ -66,14 +109,14 @@ async def analyze(request):
if resp.status_code in (200, 202): if resp.status_code in (200, 202):
result = resp.json() result = resp.json()
print(result) print(result)
g6_data = convert_to_g6_format(result)
print(g6_data)
qaList.append({ qaList.append({
"answer": item["answer"], "answer": item["answer"],
"result": result, "result": g6_data,
}) })
print(f"xh: {item['xh']}, answer: {item['answer']}, sort: {item['sort']}") print(f"xh: {item['xh']}, answer: {item['answer']}, sort: {item['sort']}")
print(resp.json()) print(resp.json())
return Response( return Response(
status_code=200, status_code=200,
description=jsonify(qaList), description=jsonify(qaList),

81
vue/src/system/GraphBuilder.vue

@ -28,8 +28,6 @@
</div> </div>
<div v-if="msg.role === 'user'" style="display: flex;align-items: flex-start; justify-content: flex-end;"> <div v-if="msg.role === 'user'" style="display: flex;align-items: flex-start; justify-content: flex-end;">
<div class="bubble"> <div class="bubble">
{{ msg.content }} {{ msg.content }}
</div> </div>
@ -52,8 +50,8 @@
type="checkbox" type="checkbox"
:id="'entity-' + i" :id="'entity-' + i"
:value="ent" :value="ent"
v-model="selectedEntities" v-model="ent.selected"
@change="" @click="handleEntitySelectionChange(msg,ent)"
/> />
<!-- 使用 for 关联到 input id --> <!-- 使用 for 关联到 input id -->
<label <label
@ -76,8 +74,8 @@
type="checkbox" type="checkbox"
:id="'rel-' + i" :id="'rel-' + i"
:value="rel" :value="rel"
v-model="selectedRelations" v-model="rel.selected"
:disabled="!isRelationEnabled(rel)" :disabled="!isRelationEnabled(msg,rel)"
/> />
<!-- 标签区域可点击 --> <!-- 标签区域可点击 -->
<label :for="'rel-' + i" class="relation-label"> <label :for="'rel-' + i" class="relation-label">
@ -91,7 +89,7 @@
<div v-if="msg.entities?.length || msg.relations?.length" style="text-align: right; display: flex; <div v-if="msg.entities?.length || msg.relations?.length" style="text-align: right; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 12px;"> margin-top: 12px;">
<button class="send-btn" @click="build()" style="border-radius: 10px"> <button class="send-btn" @click="build(msg)" style="border-radius: 10px">
构建 构建
</button> </button>
</div> </div>
@ -214,6 +212,36 @@ export default {
}; };
}, },
methods: { methods: {
handleEntitySelectionChange(msg, ent) {
//
if (!ent.selected) {
// selected false
msg.relations.forEach(rel => {
if (rel.e1 === ent.n || rel.e2 === ent.n) {
rel.selected = false;
}
});
}
console.log(`实体 "${ent.n}" 的选中状态:`, ent.selected);
//
},
// entities relations selected
addSelectedFlag(items, isRelation = false) {
if (!items) return [];
return items.map(item => {
if (isRelation) {
return {
...item,
selected: true // selected false
};
}
return {
...item,
selected: true // selected false
};
});
},
handleScroll() { handleScroll() {
const container = this.$refs.messagesContainer; const container = this.$refs.messagesContainer;
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 20; const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 20;
@ -233,19 +261,32 @@ export default {
scrollToLatest() { scrollToLatest() {
this.scrollToBottom('smooth'); this.scrollToBottom('smooth');
}, },
build(){ build(msg) {
console.log(this.selectedRelations) //
console.log(this.selectedEntities) let selectedEntities = msg.entities.filter(ent => ent.selected); // msg entities
let selectedRelations = msg.relations.filter(rel => rel.selected); // msg relations
//
let data = { let data = {
entities: this.selectedEntities, entities: selectedEntities, //
relations: this.selectedRelations relations: selectedRelations //
} };
build(data) // build
build(data);
}, },
isRelationEnabled(rel) { handleRelationSelectionChange(msg, rel) {
const selected = this.selectedEntities || []; //
const hasE1 = selected.some(ent => ent.n === rel.e1); console.log(`关系 "${rel.e1}${rel.r}${rel.e2}" 被选择了: `, rel.selected);
const hasE2 = selected.some(ent => ent.n === rel.e2); },
//
isRelationEnabled(msg, rel) {
const selectedEntities = msg.entities.filter(ent => ent.selected);
// e1 e2
const hasE1 = selectedEntities.some(ent => ent.n === rel.e1);
const hasE2 = selectedEntities.some(ent => ent.n === rel.e2);
return hasE1 && hasE2; return hasE1 && hasE2;
}, },
removeFile(){ removeFile(){
@ -394,8 +435,8 @@ export default {
let message = { let message = {
role: 'assistant', role: 'assistant',
content: res, content: res,
entities: res.entities, entities: this.addSelectedFlag(res.entities),
relations: res.relations, relations: this.addSelectedFlag(res.relations, true),
isKG: true, isKG: true,
time: this.getNowDate() time: this.getNowDate()
}; };

296
vue/src/system/GraphQA.vue

@ -7,7 +7,11 @@
/> />
<div class="medical-qa-container"> <div class="medical-qa-container">
<!-- 标题 --> <!-- 标题 -->
<div style="display: flex;align-items: center;">
<div class="title-before">
</div>
<h2 class="title">医疗问答</h2> <h2 class="title">医疗问答</h2>
</div>
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="search-box"> <div class="search-box">
@ -23,7 +27,15 @@
<div class="content-wrapper"> <div class="content-wrapper">
<!-- 左侧问答结果 --> <!-- 左侧问答结果 -->
<div class="answer-list"> <div class="answer-list">
<div class="section-title">问答结果</div> <div v-if="queryRecord">
<h2 class="section-title" style="margin-bottom: 10px;margin-top: 0px">问答提问</h2>
<div class="answer-item" style=" background-color: #165DFF;
border-color: #0066cc;
color: #fff; height: auto;max-height: 3vw">
{{queryRecord}}
</div>
</div>
<h2 class="section-title">问答结果</h2>
<div v-if="answers.length === 0" class="empty-state"> <div v-if="answers.length === 0" class="empty-state">
请输入问题进行查询... 请输入问题进行查询...
</div> </div>
@ -35,14 +47,14 @@
@click="selectGraph(index)" @click="selectGraph(index)"
:class="{ 'highlight': selected === index }" :class="{ 'highlight': selected === index }"
> >
{{ item }} {{ item.answer }}
</div> </div>
</div> </div>
</div> </div>
<!-- 右侧知识图谱 --> <!-- 右侧知识图谱 -->
<div class="knowledge-graph"> <div class="knowledge-graph">
<KnowledgeGraph /> <div ref="graphContainer" class="graph-container" id="container"></div>
</div> </div>
</div> </div>
</div> </div>
@ -53,6 +65,8 @@
<script> <script>
import Menu from "@/components/Menu.vue"; import Menu from "@/components/Menu.vue";
import {qaAnalyze} from "@/api/qa"; import {qaAnalyze} from "@/api/qa";
import {Graph} from "@antv/g6";
import {getGraph} from "@/api/graph";
export default { export default {
@ -61,24 +75,273 @@ export default {
data() { data() {
return { return {
query:"", query:"",
answers:['糖尿病不能吃什么','糖尿病不能吃什么','糖尿病不能吃什么','糖尿病不能吃什么','糖尿病不能吃什么',], answers:[],
selected:0, selected:0,
//
nodeShowLabel: true,
nodeFontSize: 12,
nodeFontColor: '#fff',
nodeShape: 'circle',
nodeSize: 50,
nodeFill: '#9FD5FF',
nodeStroke: '#5B8FF9',
nodeLineWidth: 2,
nodeFontFamily: 'Microsoft YaHei, sans-serif',
//
edgeShowLabel: true,
edgeFontSize: 10,
edgeFontColor: '#666666',
edgeType: 'quadratic',
edgeLineWidth: 2,
edgeStroke: '#b6b2b2',
edgeEndArrow: true,
edgeFontFamily: 'Microsoft YaHei, sans-serif',
queryRecord:""
}; };
}, },
methods: { methods: {
selectGraph(index){ selectGraph(index){
this.selected=index this.selected=index
this.formatData(this.answers[index].result)
}, },
handleSearch(){ handleSearch(){
this.answers=[]
let data={ let data={
text:this.query text:this.query
} }
this.queryRecord=this.query
this.query="" this.query=""
// this.answers=[{"answer":"尿",
// "result":{"nodes":[{"id":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","label":"尿","type":""},{"id":"89f9498b-7a83-4361-889b-f96dfdfb802c","label":"","type":""},{"id":"03201620-ba9f-4957-b7d3-f89c5a115e37","label":"","type":""},{"id":"b0bace0a-eedc-485c-90d3-0a5378dc5556","label":"","type":""},{"id":"ffc12d7b-60e5-4ffa-a945-a0769f6e1047","label":"","type":""},{"id":"a7e94ee7-072b-456f-bc0c-354545851c38","label":"","type":""}],"edges":[{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"03201620-ba9f-4957-b7d3-f89c5a115e37","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"b0bace0a-eedc-485c-90d3-0a5378dc5556","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"ffc12d7b-60e5-4ffa-a945-a0769f6e1047","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"a7e94ee7-072b-456f-bc0c-354545851c38","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"89f9498b-7a83-4361-889b-f96dfdfb802c","label":""}]}}]
// this.initGraph(this.answers[0].result)
// this.formatData(this.answers[0].result)
qaAnalyze(data).then(res=>{ qaAnalyze(data).then(res=>{
this.answers=res
if(this.answers.length>0){
this.initGraph(this.answers[0].result)
}
}) })
},
formatData(data){
this._graph.stopLayout();
this.clearGraphState();
const updatedEdges = data.edges.map(edge => ({
...edge,
type: this.edgeType,
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
lineWidth: this.edgeLineWidth,
label: this.edgeShowLabel,
labelFontSize: this.edgeFontSize,
labelFontFamily: this.edgeFontFamily,
labelFill: this.edgeFontColor,
},
}))
const updatedNodes = data.nodes.map(node => ({
...node,
type: this.nodeShape,
style:{
size: this.nodeSize,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
opacity: 1,
} }
}))
const updatedData = {
nodes: updatedNodes,
edges: updatedEdges
}
this.updateGraph(updatedData)
},
updateGraph(data) {
if (!this._graph) return
this._graph.setData(data)
this._graph.render()
},
initGraph(data) {
if (this._graph!=null){
this._graph.destroy()
this._graph = null;
}
const updatedEdges = data.edges.map(edge => ({
...edge,
type: this.edgeType,
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
lineWidth: this.edgeLineWidth,
label: this.edgeShowLabel,
labelFontSize: this.edgeFontSize,
labelFontFamily: this.edgeFontFamily,
labelFill: this.edgeFontColor,
},
}))
const updatedNodes = data.nodes.map(node => ({
...node,
type: this.nodeShape,
style:{
size: this.nodeSize,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
opacity: 1,
}
}))
const updatedData = {
nodes: updatedNodes,
edges: updatedEdges
}
const container = this.$refs.graphContainer;
console.log(container)
if (container!=null){
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
console.log(width)
console.log(height)
const graph = new Graph({
container,
width,
height,
layout: {
type: 'force', //
gravity: 0.3, //
repulsion: 500, //
attraction: 20, //
preventOverlap: true //
},
behaviors: [ 'zoom-canvas', 'drag-element',
'click-select','focus-element', {
type: 'hover-activate',
degree: 1,
},
{
type: 'drag-canvas',
enable: (event) => event.shiftKey === false,
},
{
type: 'brush-select',
},
],
node: {
style: {
fill: (d) => {
const label = d?.type;
if (label === 'Disease') return '#EF4444'; //
if (label === 'Drug') return '#91cc75'; // 绿
if (label === 'Symptom') return '#fac858'; //
if (label === 'Check') return '#336eee'; //
return '#59d1d4'; //
},
stroke: (d) => {
const label = d?.type;
if (label === 'Disease') return '#B91C1C';
if (label === 'Drug') return '#047857';
if (label === 'Check') return '#1D4ED8'; //
if (label === 'Symptom') return '#B45309';
return '#40999b';
},
labelText: (d) => d.label,
labelPlacement: 'center',
labelWordWrap: true,
labelMaxWidth: '150%',
labelMaxLines: 3,
labelTextOverflow: 'ellipsis',
labelTextAlign: 'center',
opacity: 1
},
state: {
active: {
lineWidth: 2,
shadowColor: '#ffffff',
shadowBlur: 10,
opacity: 1
},
inactive: {
opacity: 0.3
},
normal:{
opacity: 1
}
},
},
edge: {
style: {
labelText: (d) => d.label,
stroke: (d) => {
// target label
// const targetLabel = this._nodeLabelMap.get(d.source); // d.target ID
// // target
// if (targetLabel === 'Disease') return 'rgba(239,68,68,0.5)';
// if (targetLabel === 'Drug') return 'rgba(145,204,117,0.5)';
// if (targetLabel === 'Symptom') return 'rgba(250,200,88,0.5)';
// if (targetLabel === 'Check') return 'rgba(51,110,238,0.5)'; //
return 'rgba(89,209,212,0.5)'; // default
},
// labelFill: (d) => {
// // target label
// const targetLabel = this._nodeLabelMap.get(d.target); // d.target ID
// // target
//
// if (targetLabel === 'Disease') return '#ff4444';
// if (targetLabel === 'Drug') return '#2f9b70';
// if (targetLabel === 'Symptom') return '#f89775';
// return '#6b91ff'; // default
// }
},
state: {
selected: {
stroke: '#1890FF',
lineWidth: 2,
},
highlight: {
halo: true,
haloStroke: '#1890FF',
haloLineWidth: 6,
haloStrokeOpacity: 0.3,
lineWidth: 3,
opacity: 1
},
inactive: {
opacity: 0.3
},
normal:{
opacity: 1
}
},
},
data:updatedData,
});
graph.render();
this._graph = graph
this._graph?.fitView()
}
},
}, },
}; };
@ -96,26 +359,21 @@ export default {
} }
.title { .title {
font-size: 20px; font-size: 22px;
margin-bottom: 20px; margin-bottom: 20px;
padding-left: 12px;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
color: #165DFF; color: #165DFF;
margin-left: 15px; margin-left: 11px;
} }
.title::before { .title-before {
content: ''; content: '';
width: 6px; width: 8px;
height: 16px; height: 22px;
background-color: #165DFF; background-color: #165DFF;
position: absolute; border-radius: 5px;
left: 0;
top: 50%;
transform: translateY(-50%);
border-radius: 2px;
} }
.search-box { .search-box {
@ -161,17 +419,17 @@ export default {
.section-title::before { .section-title::before {
content: ''; content: '';
width: 6px; width: 6px;
height: 12px; height: 16px;
background-color: #165DFF; background-color: #165DFF;
position: absolute; position: absolute;
left: 0; left: 0;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
border-radius: 2px; border-radius: 5px;
} }
.answer-items { .answer-items {
height: 30vw; max-height: 25vw;
overflow-y: auto; overflow-y: auto;
padding-right: 10px; padding-right: 10px;
cursor: pointer; cursor: pointer;

Loading…
Cancel
Save