fix: expose incomplete AI analysis and track source versions
This commit is contained in:
@@ -89,7 +89,7 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo
|
||||
|
||||
## Doğrulama
|
||||
|
||||
`npm test`: 37/37 geçti (son yeniden çalıştırma: 6 Eylül 2026).
|
||||
`npm test`: 39/39 geçti (son yeniden çalıştırma: 6 Eylül 2026).
|
||||
`npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor.
|
||||
`node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti.
|
||||
|
||||
@@ -121,3 +121,10 @@ Hazırlık formuna gerçek XLSX çıktısı eklendi; aynı seçili satırlar ve
|
||||
İki XLSX testi dosyayı üretip yeniden okuyarak 100 satır, Türkçe, 125,50 fiyat ve 25.100 toplamını; ayrıca 0,335 hassasiyet ve 1,01 yuvarlamasını doğrular. Kullanıcı metni formüle çevrilmez. PDF birim fiyat gösterimi de sekiz ondalığa kadar hassasiyeti korur. Tarayıcı indirme ve masaüstü Excel baskı görünümü kabulü bekliyor.
|
||||
|
||||
ExcelJS ayrı, ihtiyaç anında yüklenen pakettir. Node bağımlılığındaki UUID için uyumlu 11.1.1+ override ve mevcut nanoid yaması uygulandı; npm audit sıfır bulgu döndürdü. Bu sonuç paketlerin önceden derlenmiş tarayıcı içeriğinin bağımsız denetimi değildir. Mevcut büyük paket uyarıları sürer.
|
||||
|
||||
|
||||
## Analiz bütünlüğü
|
||||
|
||||
AI çıktısına kaynak içerik özeti (SHA-256 sürümü), kaynak/işlenen karakter sayısı, bölüm çıkarım durumu ve kısmi analiz gerekçeleri eklendi. Girdi sınırında kesilen metin, onarılan yarım JSON ve bilinen kaynak sayısından az kalem kısmi sayılır. Hazırlık formu bunu saklar ve çıktıyı engeller. Boş bölüm, şartın bulunmadığı olarak yorumlanmaz. Derin analizdeki 20 kalem kısıtı kaldırıldı; çıktı sınırını aşan analiz yine eksik işaretlenir.
|
||||
|
||||
İki test kesilme, onarma, eksik kalem, boş bölüm ve kaynak değişimini doğrular. 39 test ve derleme geçti. Bu adım tam uzun doküman işleme değildir: parçalı analiz/birleştirme, OCR, sayfa/madde atıfları ve hukuki çelişki denetimi hâlâ açık. Kısmi analizi tamamlamak için gerekli uzun doküman akışı ayrıca geliştirilmeli; yalnız uyarı eklenmiş olması D06 tamamlanması değildir.
|
||||
|
||||
+6
-3
@@ -1,3 +1,4 @@
|
||||
import { qualifyAnalysis } from './analysis-quality.mjs';
|
||||
/**
|
||||
* Gerçek AI şartname analizi — OpenRouter öncelikli, çoklu sağlayıcı fallback.
|
||||
* Bağımlılık yok (Node 18+ built-in fetch).
|
||||
@@ -163,7 +164,7 @@ YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema:
|
||||
}
|
||||
|
||||
Kurallar:
|
||||
- criticalDetails en fazla 12, documentChecklist en fazla 18, deadlines en fazla 10, roadmap en fazla 10, itemsAndSpecs en fazla 20 madde olsun.
|
||||
- criticalDetails en fazla 12, documentChecklist en fazla 18, deadlines en fazla 10, roadmap en fazla 10 madde olsun. itemsAndSpecs içinde kaynakta bulunan kalemleri eksiltme veya birleştirme; sığmayan listeyi tamamlanmış gibi sunma.
|
||||
- itemsAndSpecs'i teknik şartnamedeki kalem/miktar tablolarından çıkar; keySpecs'e yalnızca teklifi bağlayan zorunlu evsafları yaz (ör. "en az 17x17 inç dedektör", "IP54"), tanıtım cümlelerini yazma.
|
||||
- estimatedUnitPrice / estimatedTotal yalnızca metinde geçen bir yaklaşık maliyet, tahmini bedel veya birim fiyat varsa doldurulur; TAHMİN ÜRETME, yoksa null bırak. Sayıları yalnızca rakam olarak yaz (para birimi simgesi, nokta/virgül ayracı koyma).
|
||||
- roadmap adımlarını mantıklı sırayla ve step alanını 1'den başlayarak numaralandır.
|
||||
@@ -282,7 +283,7 @@ function extractJson(raw) {
|
||||
const govde = s.slice(start, end + 1);
|
||||
try { return JSON.parse(govde); } catch (e) {
|
||||
const onarilmis = kesikJsonOnar(s.slice(start));
|
||||
if (onarilmis) return onarilmis;
|
||||
if (onarilmis) return {...onarilmis,_outputRepaired:true};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -352,7 +353,9 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
|
||||
} else {
|
||||
raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens);
|
||||
}
|
||||
const result = extractJson(raw);
|
||||
const parsed = extractJson(raw);
|
||||
const result = qualifyAnalysis(parsed,{text,fileName,limit:derin ? 90000 : 60000,
|
||||
expectedItems:tender?.items?.length || 0,repaired:parsed._outputRepaired === true});
|
||||
attempts.push({ id, ok: true, yol: route.yol });
|
||||
return {
|
||||
result,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expectedItems=0,repaired=false}={}) {
|
||||
if(!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Analiz nesne biçiminde değil.');
|
||||
const clean={...result};delete clean._outputRepaired;
|
||||
const sections={};
|
||||
for(const key of ['itemsAndSpecs','documentChecklist','qualificationCriteria','risksAndPenalties','roadmap']) {
|
||||
if(clean[key] != null && !Array.isArray(clean[key])) throw new Error(`${key} liste biçiminde değil.`);
|
||||
sections[key]=clean[key]?.length ? 'extracted' : 'not_extracted';
|
||||
}
|
||||
const source=String(text),issues=[];
|
||||
if(source.length>limit)issues.push('Kaynak metnin tamamı analiz edilmedi.');
|
||||
if(repaired)issues.push('Analiz yanıtı kesildi; yalnız kurtarılabilen alanlar gösteriliyor.');
|
||||
if(expectedItems>0 && (clean.itemsAndSpecs?.length || 0)<expectedItems)issues.push(`Kaynakta ${expectedItems} kalem var; analiz listesi daha kısa.`);
|
||||
const version=createHash('sha256').update(source).digest('hex');
|
||||
return {...clean,documentVersion:version,sourceReview:{status:issues.length?'partial':'review_required',
|
||||
fileName,sourceCharacters:source.length,analyzedCharacters:Math.min(source.length,limit),
|
||||
outputRepaired:repaired,sections,issues}};
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
useEffect(()=>{mounted.current=true;return ()=>{mounted.current=false;printWindow.current?.close();};},[]);
|
||||
const saveDraft = async () => {
|
||||
const submitted = latestDraft.current;
|
||||
const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null}}});
|
||||
const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null,sourceReview:effectiveResults.sourceReview || null}}});
|
||||
if (saved && mounted.current) { setDirty(latestDraft.current !== submitted); setHata(null); }
|
||||
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
|
||||
};
|
||||
@@ -122,7 +122,8 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || '');
|
||||
const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1]));
|
||||
const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,deadlineDate:tenderDateKey(tender)});
|
||||
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0;
|
||||
const analizEksik = effectiveResults.sourceReview?.status === 'partial';
|
||||
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik;
|
||||
|
||||
const formVerisi = () => ({
|
||||
...firma,
|
||||
@@ -213,6 +214,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{analizEksik && <p role="alert" className="text-xs text-red-700">Analiz kısmi: kaynak veya kalem listesi eksik. Tam dokümanı ve resmi cetveli kontrol ederek analizi tamamlayın.</p>}
|
||||
{/* Sizden beklenen bilgiler */}
|
||||
{eksikler.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-3.5 flex gap-2.5">
|
||||
|
||||
@@ -73,6 +73,12 @@ export const DeepAnalysisReport = ({ results, tender: selectedTender }) => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{results.sourceReview && <div className="border border-amber-300 bg-amber-50 rounded-xl p-3 text-xs space-y-1">
|
||||
<p className="font-bold">{results.sourceReview.status === 'partial' ? 'Kısmi analiz — kontrol gerekli' : 'Analiz üretildi — kaynak doğrulaması gerekli'}</p>
|
||||
<p>Kaynak: {results.sourceReview.fileName || 'Gönderilen metin'} · {results.sourceReview.analyzedCharacters} / {results.sourceReview.sourceCharacters} karakter işlendi</p>
|
||||
{(results.sourceReview.issues || []).map(issue=><p key={issue} role="alert">{issue}</p>)}
|
||||
<p>Boş bir bölüm, ilgili şartın kaynakta bulunmadığı anlamına gelmez.</p>
|
||||
</div>}
|
||||
|
||||
{/* İşin kapsamı */}
|
||||
{scopeOfWork && (
|
||||
|
||||
@@ -407,7 +407,7 @@ export const TenderDetailModal = () => {
|
||||
<Sparkles className="w-6 h-6 text-amber-400" />
|
||||
<div>
|
||||
<h3 className="font-black text-lg">Yapay Zeka Teklif & Risk Değerlendirmesi</h3>
|
||||
<p className="text-xs text-blue-200">Teknik şartname ve geçmiş ihale sonuçları analiz edildi.</p>
|
||||
<p className="text-xs text-blue-200">Analizi başlatarak işlenen kaynağı ve kontrol gerektiren alanları inceleyin.</p>
|
||||
</div>
|
||||
</div>
|
||||
{tender.aiMatchScore != null && (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {qualifyAnalysis} from '../server/analysis-quality.mjs';
|
||||
test('truncated input and repaired output are partial, never complete analysis',()=>{
|
||||
const r=qualifyAnalysis({itemsAndSpecs:[{name:'Kit'}]},{text:'a'.repeat(100),limit:50,expectedItems:100,repaired:true});
|
||||
assert.equal(r.sourceReview.status,'partial');assert.equal(r.sourceReview.issues.length,3);
|
||||
assert.equal(r.sourceReview.analyzedCharacters,50);
|
||||
assert.equal(r.sourceReview.sections.documentChecklist,'not_extracted');
|
||||
});
|
||||
test('source versions change with content and empty sections do not claim absence',()=>{
|
||||
const a=qualifyAnalysis({documentChecklist:[]},{text:'kaynak A'});
|
||||
const b=qualifyAnalysis({documentChecklist:[]},{text:'kaynak B'});
|
||||
assert.notEqual(a.documentVersion,b.documentVersion);
|
||||
assert.equal(a.sourceReview.status,'review_required');
|
||||
assert.equal(a.sourceReview.sections.documentChecklist,'not_extracted');
|
||||
assert.throws(()=>qualifyAnalysis({itemsAndSpecs:'kalem yok'}));
|
||||
});
|
||||
Reference in New Issue
Block a user