feat(study): 이론↔문제 브리지 (Stage B) — 개념별 정답률·약점 개념 지도
이론공부 B→A→C 의 B. 완성된 문제풀이에 이론 연결(약점 구동).
- 마이그 382 study_concept_links(개념 doc↔기출, FK 없음) + 백필 SQL(임베딩 코사인 top-k=10·threshold 0.62 → 2362링크·284개념·964문항)
- concept_links 서비스(related_questions·weakness_map 롤업) + GET /concepts/{id}/questions·/concepts/weakness-map(라우트 순서=weakness-map 먼저)
- 리더 관련기출 섹션(정답률·문항 stub→문항상세) + 홈 약점개념 위젯
- 적대리뷰 반영: Promise.all 격리(weakness-map 실패→코어 대시보드 블랙아웃 방지)·q.subject null 폴백. 백필=배포 후 트랜잭션 래핑 실행. 문제풀이 무접촉
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from core.auth import get_current_user
|
|||||||
from core.database import get_session
|
from core.database import get_session
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services.study import concept_curriculum as cc
|
from services.study import concept_curriculum as cc
|
||||||
|
from services.study import concept_links as cl
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -43,6 +44,20 @@ async def get_today_concepts(
|
|||||||
return await cc.today_concepts(session, user.id, topic_id, limit)
|
return await cc.today_concepts(session, user.id, topic_id, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/concepts/weakness-map")
|
||||||
|
async def get_weakness_map(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
topic_id: int = DEFAULT_TOPIC_ID,
|
||||||
|
limit: int = 12,
|
||||||
|
):
|
||||||
|
"""개념 약점 지도 — 링크된 기출 정답률로 약점 개념(정답률<60%) 우선(이론↔문제)."""
|
||||||
|
name = await cc._topic_name(session, topic_id)
|
||||||
|
if not name:
|
||||||
|
return {"weak": [], "weak_total": 0, "evaluated_total": 0}
|
||||||
|
return await cl.weakness_map(session, user.id, name, limit)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/concepts/{doc_id}")
|
@router.get("/concepts/{doc_id}")
|
||||||
async def get_concept_detail(
|
async def get_concept_detail(
|
||||||
doc_id: int,
|
doc_id: int,
|
||||||
@@ -57,6 +72,17 @@ async def get_concept_detail(
|
|||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/concepts/{doc_id}/questions")
|
||||||
|
async def get_concept_questions(
|
||||||
|
doc_id: int,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
limit: int = 20,
|
||||||
|
):
|
||||||
|
"""개념 관련 기출 + 내 정답률 (이론↔문제 브리지)."""
|
||||||
|
return await cl.related_questions(session, user.id, doc_id, limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/concepts/{doc_id}/read")
|
@router.post("/concepts/{doc_id}/read")
|
||||||
async def post_concept_read(
|
async def post_concept_read(
|
||||||
doc_id: int,
|
doc_id: int,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""concept_links — 이론↔문제 브리지 롤업 (Stage B).
|
||||||
|
|
||||||
|
study_concept_links(개념 doc ↔ 기출문항, 임베딩 코사인) + study_question_progress(내 풀이상태)를
|
||||||
|
조인해 (a) 개념별 관련 기출 + 내 정답률(related_questions), (b) 개념 약점 지도(weakness_map) 산출.
|
||||||
|
읽기 전용 집계 · LLM 0. 링크 적재는 scripts/concept_links_backfill.sql(임베딩) 배치.
|
||||||
|
정답률 = 링크된 문항 중 progress.last_outcome 기준(attempted=풀이이력 보유, correct=최근정답).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
_ACCURACY_WEAK_PCT = 60 # 정답률 < 60% = 약점(attempted>0 일 때만)
|
||||||
|
|
||||||
|
_AGG_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS linked,
|
||||||
|
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
|
||||||
|
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
|
||||||
|
FROM study_concept_links l
|
||||||
|
LEFT JOIN study_question_progress pr
|
||||||
|
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
|
||||||
|
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_QROWS_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT q.id AS id, q.subject AS subject, q.exam_round AS exam_round,
|
||||||
|
q.exam_question_number AS qnum, l.score AS score,
|
||||||
|
pr.last_outcome AS last_outcome, pr.review_stage AS review_stage
|
||||||
|
FROM study_concept_links l
|
||||||
|
JOIN study_questions q ON q.id = l.question_id AND q.deleted_at IS NULL AND q.is_active
|
||||||
|
LEFT JOIN study_question_progress pr
|
||||||
|
ON pr.study_question_id = q.id AND pr.user_id = :uid
|
||||||
|
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
|
||||||
|
ORDER BY l.score DESC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_WEAKNESS_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT d.id AS doc_id, d.title AS title,
|
||||||
|
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
|
||||||
|
count(l.id) AS linked,
|
||||||
|
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
|
||||||
|
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
|
||||||
|
FROM documents d
|
||||||
|
JOIN study_concept_links l ON l.concept_doc_id = d.id AND l.link_source = 'embedding'
|
||||||
|
LEFT JOIN study_question_progress pr
|
||||||
|
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
|
||||||
|
WHERE d.user_tags::text LIKE :like AND d.deleted_at IS NULL
|
||||||
|
GROUP BY d.id, d.title, subject
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def related_questions(
|
||||||
|
session: AsyncSession, user_id: int, doc_id: int, limit: int = 20
|
||||||
|
) -> dict:
|
||||||
|
"""개념 doc 의 관련 기출 + 내 정답률(전체 링크 기준 집계 + 상위 N 표시용)."""
|
||||||
|
agg = (
|
||||||
|
await session.execute(_AGG_SQL, {"uid": user_id, "doc_id": doc_id})
|
||||||
|
).mappings().first()
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
_QROWS_SQL, {"uid": user_id, "doc_id": doc_id, "limit": limit}
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
linked = (agg["linked"] if agg else 0) or 0
|
||||||
|
attempted = (agg["attempted"] if agg else 0) or 0
|
||||||
|
correct = (agg["correct"] if agg else 0) or 0
|
||||||
|
accuracy = round(100 * correct / attempted) if attempted else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"linked": linked,
|
||||||
|
"attempted": attempted,
|
||||||
|
"correct": correct,
|
||||||
|
"accuracy": accuracy,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"subject": r["subject"],
|
||||||
|
"exam_round": r["exam_round"],
|
||||||
|
"qnum": r["qnum"],
|
||||||
|
"score": round(r["score"], 3) if r["score"] is not None else None,
|
||||||
|
"last_outcome": r["last_outcome"],
|
||||||
|
"review_stage": r["review_stage"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def weakness_map(
|
||||||
|
session: AsyncSession, user_id: int, topic_name: str, limit: int = 12
|
||||||
|
) -> dict:
|
||||||
|
"""개념 약점 지도 — 링크된 기출 정답률로 개념 채색. 약점(attempted>0·정답률<60%) 우선 정렬."""
|
||||||
|
like = f"%@library/{topic_name}/%"
|
||||||
|
rows = (
|
||||||
|
await session.execute(_WEAKNESS_SQL, {"uid": user_id, "like": like})
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
concepts = []
|
||||||
|
for r in rows:
|
||||||
|
attempted = r["attempted"] or 0
|
||||||
|
correct = r["correct"] or 0
|
||||||
|
accuracy = round(100 * correct / attempted) if attempted else None
|
||||||
|
if accuracy is None:
|
||||||
|
state = "unattempted"
|
||||||
|
elif accuracy < _ACCURACY_WEAK_PCT:
|
||||||
|
state = "weak"
|
||||||
|
else:
|
||||||
|
state = "ok"
|
||||||
|
concepts.append(
|
||||||
|
{
|
||||||
|
"doc_id": r["doc_id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"subject": r["subject"],
|
||||||
|
"linked": r["linked"] or 0,
|
||||||
|
"attempted": attempted,
|
||||||
|
"accuracy": accuracy,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 약점 우선(정답률 오름차순) → 미평가는 뒤로. 홈 위젯용 상위 N.
|
||||||
|
weak = sorted(
|
||||||
|
[c for c in concepts if c["state"] == "weak"],
|
||||||
|
key=lambda c: (c["accuracy"], -c["attempted"], c["doc_id"]),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"weak": weak[:limit],
|
||||||
|
"weak_total": len(weak),
|
||||||
|
"evaluated_total": sum(1 for c in concepts if c["state"] != "unattempted"),
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/toast';
|
import { addToast } from '$lib/stores/toast';
|
||||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck } from 'lucide-svelte';
|
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target } from 'lucide-svelte';
|
||||||
|
|
||||||
let cardReviewCount = $state(0);
|
let cardReviewCount = $state(0);
|
||||||
let questionFlagCount = $state(0);
|
let questionFlagCount = $state(0);
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// 오늘의 공부 (이론 홈)
|
// 오늘의 공부 (이론 홈)
|
||||||
let curriculum = $state(null);
|
let curriculum = $state(null);
|
||||||
let todayConcepts = $state([]);
|
let todayConcepts = $state([]);
|
||||||
|
let weakConcepts = $state([]); // 약점 개념(관련 기출 정답률 낮음)
|
||||||
let dashLoading = $state(true);
|
let dashLoading = $state(true);
|
||||||
|
|
||||||
let readPct = $derived(
|
let readPct = $derived(
|
||||||
@@ -28,10 +29,15 @@
|
|||||||
curriculum = cur;
|
curriculum = cur;
|
||||||
todayConcepts = today?.concepts ?? [];
|
todayConcepts = today?.concepts ?? [];
|
||||||
} catch {
|
} catch {
|
||||||
// 대시보드 실패해도 허브 나머지는 동작 (조용히)
|
// 코어 대시보드 실패해도 허브 나머지는 동작 (조용히)
|
||||||
} finally {
|
} finally {
|
||||||
dashLoading = false;
|
dashLoading = false;
|
||||||
}
|
}
|
||||||
|
// 약점 개념 = 비차단(신규 엔드포인트 실패해도 코어 대시보드 블랙아웃 방지)
|
||||||
|
try {
|
||||||
|
const weak = await api('/study/concepts/weakness-map?limit=5');
|
||||||
|
weakConcepts = weak?.weak ?? [];
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(doc) {
|
async function markRead(doc) {
|
||||||
@@ -121,6 +127,22 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if weakConcepts.length > 0}
|
||||||
|
<div class="mt-4 pt-3 border-t border-default">
|
||||||
|
<div class="text-xs text-dim mb-2 flex items-center gap-1.5">
|
||||||
|
<Target size={13} class="text-error" /> 약점 개념 <span class="text-faint">(관련 기출 정답률 낮음)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each weakConcepts as w (w.doc_id)}
|
||||||
|
<a href="/study/read/{w.doc_id}"
|
||||||
|
class="text-xs rounded-full border border-error/40 bg-error/10 text-error px-3 py-1 hover:bg-error/20 transition-colors">
|
||||||
|
{w.title.replace(/^\d+_/, '')} <span class="font-semibold">{w.accuracy}%</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,12 @@
|
|||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||||
import { BookOpen, ArrowLeft, Eye, EyeOff, Check, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
import { BookOpen, ArrowLeft, Eye, EyeOff, Check, ChevronLeft, ChevronRight, FileQuestion } from 'lucide-svelte';
|
||||||
|
|
||||||
let docId = $derived($page.params.docId);
|
let docId = $derived($page.params.docId);
|
||||||
|
|
||||||
let concept = $state(null);
|
let concept = $state(null);
|
||||||
|
let relatedQ = $state(null); // 관련 기출(이론↔문제, 비차단)
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let notFound = $state(false);
|
let notFound = $state(false);
|
||||||
let mode = $state('read'); // 'read' | 'recall'(떠올리기)
|
let mode = $state('read'); // 'read' | 'recall'(떠올리기)
|
||||||
@@ -25,12 +26,17 @@
|
|||||||
let marking = $state(false);
|
let marking = $state(false);
|
||||||
|
|
||||||
const STAGE_LABEL = { 0: '복습 시작', 1: '복습 1단계', 2: '복습 2단계', 3: '복습 3단계', 4: '학습 완료' };
|
const STAGE_LABEL = { 0: '복습 시작', 1: '복습 1단계', 2: '복습 2단계', 3: '복습 3단계', 4: '학습 완료' };
|
||||||
|
const OUTCOME_MARK = { correct: '○', wrong: '✕', unsure: '?' };
|
||||||
|
const OUTCOME_CLASS = { correct: 'text-success', wrong: 'text-error', unsure: 'text-warning' };
|
||||||
|
const outcomeMark = (o) => OUTCOME_MARK[o] ?? '–';
|
||||||
|
const outcomeClass = (o) => OUTCOME_CLASS[o] ?? 'text-faint';
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const reqId = docId; // in-flight 가드: 백링크 연타 시 stale 응답 무시
|
const reqId = docId; // in-flight 가드: 백링크 연타 시 stale 응답 무시
|
||||||
loading = true;
|
loading = true;
|
||||||
notFound = false;
|
notFound = false;
|
||||||
concept = null;
|
concept = null;
|
||||||
|
relatedQ = null;
|
||||||
revealed = {};
|
revealed = {};
|
||||||
mode = 'read';
|
mode = 'read';
|
||||||
try {
|
try {
|
||||||
@@ -41,9 +47,15 @@
|
|||||||
if (reqId !== docId) return;
|
if (reqId !== docId) return;
|
||||||
if (e?.status === 404) notFound = true;
|
if (e?.status === 404) notFound = true;
|
||||||
else addToast('error', '개념을 불러오지 못했습니다');
|
else addToast('error', '개념을 불러오지 못했습니다');
|
||||||
|
return; // 본문 실패 → 관련기출 스킵
|
||||||
} finally {
|
} finally {
|
||||||
if (reqId === docId) loading = false;
|
if (reqId === docId) loading = false;
|
||||||
}
|
}
|
||||||
|
// 관련 기출(비차단 — 실패해도 본문 표시엔 영향 없음)
|
||||||
|
try {
|
||||||
|
const rq = await api(`/study/concepts/${reqId}/questions?limit=6`);
|
||||||
|
if (reqId === docId) relatedQ = rq;
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// $effect 가 마운트 1회 + docId 변경(백링크/이전·다음) 재로드를 모두 커버 (onMount 불필요)
|
// $effect 가 마운트 1회 + docId 변경(백링크/이전·다음) 재로드를 모두 커버 (onMount 불필요)
|
||||||
@@ -202,6 +214,29 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- 관련 기출 (이론↔문제 브리지) -->
|
||||||
|
{#if relatedQ && relatedQ.linked > 0}
|
||||||
|
<section class="mb-5 rounded-lg border border-default bg-surface p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-text mb-2 flex items-center gap-1.5">
|
||||||
|
<FileQuestion size={15} class="text-accent" /> 관련 기출
|
||||||
|
<span class="ml-1 text-xs font-normal text-dim">
|
||||||
|
{relatedQ.linked}문항{#if relatedQ.accuracy !== null} · 정답률 <span class="{relatedQ.accuracy < 60 ? 'text-error' : 'text-text'} font-medium">{relatedQ.accuracy}%</span>{:else} · 아직 안 풂{/if}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{#each relatedQ.questions as q (q.id)}
|
||||||
|
<li>
|
||||||
|
<a href="/study/topics/4/questions/{q.id}"
|
||||||
|
class="flex items-center gap-2 text-xs py-1 text-dim hover:text-accent transition-colors">
|
||||||
|
<span class="{outcomeClass(q.last_outcome)} shrink-0 w-4 text-center font-bold">{outcomeMark(q.last_outcome)}</span>
|
||||||
|
<span class="truncate">{q.subject ?? '기출'}{#if q.exam_round} · {q.exam_round}{/if}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- 액션바 -->
|
<!-- 액션바 -->
|
||||||
<div class="flex items-center gap-2 border-t border-default pt-4 mt-2">
|
<div class="flex items-center gap-2 border-t border-default pt-4 mt-2">
|
||||||
{#if concept.prev_id}
|
{#if concept.prev_id}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 382_study_concept_links.sql — 개념문서 ↔ 기출문항 링크 (이론↔문제 브리지, Stage B).
|
||||||
|
-- concept_doc_id=documents.id, question_id=study_questions.id — FK 없음(hot 테이블 락 회피, 선례).
|
||||||
|
-- link_source: 'embedding'(bge-m3 코사인 top-k, 주력) | 'ref'(해설 .md 참조, 후속 enrichment).
|
||||||
|
-- score=코사인 유사도(0~1). UNIQUE(doc,question,source) — source별 공존 허용(재튜닝=source 전삭제 후 재삽입).
|
||||||
|
CREATE TABLE IF NOT EXISTS study_concept_links (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
concept_doc_id bigint NOT NULL,
|
||||||
|
question_id bigint NOT NULL,
|
||||||
|
link_source text NOT NULL,
|
||||||
|
score double precision,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_concept_link UNIQUE (concept_doc_id, question_id, link_source)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_concept_links_doc ON study_concept_links(concept_doc_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_concept_links_q ON study_concept_links(question_id);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- concept_links_backfill.sql — 개념↔문항 임베딩 링크 재생성 (Stage B, 멱등·재실행 안전).
|
||||||
|
-- 정찰 확정: bge-m3 1024d 코사인, per-concept top-k=10, threshold 0.62 → ~2362링크·284/289개념·964문항.
|
||||||
|
-- 재튜닝 시 DELETE(embedding 소스만) 후 재삽입 = ref 링크(후속) 불변. 개념 doc = 가스기사 태그.
|
||||||
|
DELETE FROM study_concept_links WHERE link_source = 'embedding';
|
||||||
|
INSERT INTO study_concept_links (concept_doc_id, question_id, link_source, score)
|
||||||
|
WITH cd AS (
|
||||||
|
SELECT id, embedding FROM documents
|
||||||
|
WHERE user_tags::text LIKE '%@library/가스기사/%'
|
||||||
|
AND deleted_at IS NULL AND embedding IS NOT NULL
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT cd.id AS concept_doc_id, q.id AS question_id,
|
||||||
|
1 - (q.embedding <=> cd.embedding) AS score,
|
||||||
|
row_number() OVER (PARTITION BY cd.id ORDER BY q.embedding <=> cd.embedding) AS rn
|
||||||
|
FROM cd
|
||||||
|
JOIN study_questions q
|
||||||
|
ON q.study_topic_id = 4 AND q.embedding IS NOT NULL
|
||||||
|
AND q.deleted_at IS NULL AND q.is_active
|
||||||
|
)
|
||||||
|
SELECT concept_doc_id, question_id, 'embedding', score
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn <= 10 AND score >= 0.62
|
||||||
|
ON CONFLICT (concept_doc_id, question_id, link_source) DO NOTHING;
|
||||||
Reference in New Issue
Block a user