Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Password input field in connector creation form (both standalone page and modal)
- `migrate_connector_secrets` management command to migrate existing env-var-based credentials into encrypted secrets
- `cryptography>=42.0` dependency
- **Elasticsearch connector** — connect to Elasticsearch clusters and ingest documents
- API key, basic auth, bearer token, and Elastic Cloud (`cloud_id`) authentication
- PIT + `search_after` pagination with automatic fallback to `helpers.scan()` for older ES versions
- Configurable field mapping (`content_field`, `title_field`, `author_field`, `date_field`)
- Custom Elasticsearch Query DSL filter support
- TLS certificate verification control
- HTML content auto-detection
- Fallback content extraction when configured content field is missing
- 35 unit tests covering all connector functionality
- Apache 2.0 LICENSE file
- `CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md`
- `SECRET_KEY` startup validation (refuses to start with placeholder key in production)
Expand Down Expand Up @@ -55,6 +64,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SCORE 0-100 scoring with A-E letter grades
- PDF report generation with radar charts
- RAG chat interface
- SharePoint, Confluence, and Elasticsearch connectors
- SharePoint and Confluence connectors
- Docker deployment support
- French and English internationalization
3 changes: 3 additions & 0 deletions connectors/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ def _fetch_http(self, source_id: str) -> RawDocument:
resp = httpx.get(source_id, timeout=60, follow_redirects=True)
resp.raise_for_status()

if not resp.content:
raise ValueError(f"Empty response from {source_id} (HTTP {resp.status_code})")

content_type = resp.headers.get("content-type", "").split(";")[0].strip()
etag = resp.headers.get("etag", "")
version = etag or hashlib.sha256(resp.content).hexdigest()[:16]
Expand Down
34 changes: 28 additions & 6 deletions connectors/templates/connectors/_connector_cards.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ <h2 class="mb-0">{% trans "Connecteurs" %}</h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
{% elif conn.connector_type == 'elasticsearch' %}
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="10" ry="4"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(120 12 12)"/></svg>
{% elif conn.connector_type == 'zipupload' %}
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{% else %}
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{% endif %}
Expand Down Expand Up @@ -166,14 +168,16 @@ <h2 class="mb-0">{% trans "Connecteurs" %}</h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
{% elif val == 'elasticsearch' %}
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="10" ry="4"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(120 12 12)"/></svg>
{% elif val == 'zipupload' %}
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{% else %}
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{% endif %}
</div>
<div>
<div class="conn-type-card-name">{{ label }}</div>
<div class="conn-type-card-desc">
{% if val == 'sharepoint' %}{% trans "Biblioth&egrave;ques SharePoint Online" %}{% elif val == 'confluence' %}{% trans "Espaces Confluence Cloud" %}{% elif val == 'elasticsearch' %}{% trans "Index Elasticsearch" %}{% else %}{% trans "Fichiers locaux ou URL" %}{% endif %}
{% if val == 'sharepoint' %}{% trans "Biblioth&egrave;ques SharePoint Online" %}{% elif val == 'confluence' %}{% trans "Espaces Confluence Cloud" %}{% elif val == 'elasticsearch' %}{% trans "Index Elasticsearch" %}{% elif val == 'zipupload' %}{% trans "Archive ZIP de documents" %}{% else %}{% trans "Fichiers locaux ou URL" %}{% endif %}
</div>
</div>
</a>
Expand Down Expand Up @@ -212,7 +216,7 @@ <h2 class="mb-0">{% trans "Connecteurs" %}</h2>
<div class="cf-title" id="cf-title">{% trans "Connecter SharePoint &agrave; SCORE" %}</div>
<div class="cf-subtitle">{% trans "Configurez les param&egrave;tres de connexion pour indexer automatiquement vos documents dans SCORE." %}</div>

<form method="post" action="{% url 'connector-create' %}">
<form method="post" action="{% url 'connector-create' %}" enctype="multipart/form-data" data-turbo="false">
{% csrf_token %}

<!-- Name -->
Expand Down Expand Up @@ -319,6 +323,15 @@ <h2 class="mb-0">{% trans "Connecteurs" %}</h2>
</div>
</div>

<!-- Zip Upload config -->
<div id="cf-config-zipupload" class="cf-connector-config" style="display:none">
<div class="cf-group">
<label class="cf-label">{% trans "Fichier ZIP" %}</label>
<input type="file" name="zip_file" class="cf-input" accept=".zip,application/zip">
<div class="cf-hint">{% trans "S&eacute;lectionnez une archive ZIP contenant vos documents." %}</div>
</div>
</div>

<!-- Security banner -->
<div class="sec-banner-inline">
<div class="sec-inline-row">
Expand Down Expand Up @@ -393,13 +406,15 @@ <h2 class="mb-0">{% trans "Connecteurs" %}</h2>
sharepoint: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>',
confluence: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
elasticsearch: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="10" ry="4"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(120 12 12)"/></svg>',
generic: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
generic: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
zipupload: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>'
};
var titles = {
sharepoint: 'Connecter SharePoint \u00e0 SCORE',
confluence: 'Connecter Confluence \u00e0 SCORE',
elasticsearch: 'Connecter Elasticsearch \u00e0 SCORE',
generic: 'Connecter une source \u00e0 SCORE'
generic: 'Connecter une source \u00e0 SCORE',
zipupload: 'Importer un fichier ZIP dans SCORE'
};

var select = document.getElementById('cf-connector-type');
Expand All @@ -410,9 +425,16 @@ <h2 class="mb-0">{% trans "Connecteurs" %}</h2>
iconBox.className = 'cf-icon-box ' + (type || 'generic');
iconBox.innerHTML = icons[type] || icons.generic;
titleEl.textContent = titles[type] || titles.generic;
modalEl.querySelectorAll('.cf-connector-config').forEach(function(el) { el.style.display = 'none'; });
/* Hide all config panels and disable their required fields */
modalEl.querySelectorAll('.cf-connector-config').forEach(function(el) {
el.style.display = 'none';
el.querySelectorAll('[required]').forEach(function(inp) { inp.disabled = true; });
});
var target = document.getElementById('cf-config-' + type);
if (target) target.style.display = 'block';
if (target) {
target.style.display = 'block';
target.querySelectorAll('[disabled]').forEach(function(inp) { inp.disabled = false; });
}
}

select.addEventListener('change', function() { updateModal(this.value); });
Expand Down
62 changes: 55 additions & 7 deletions connectors/templates/connectors/create.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
.cf-icon-box.confluence { background: rgba(13,202,240,0.12); color: #22d3ee; }
.cf-icon-box.elasticsearch { background: rgba(254,210,48,0.12); color: #fed230; }
.cf-icon-box.generic { background: rgba(108,113,126,0.12); color: #9ca3af; }
.cf-icon-box.zipupload { background: rgba(168,85,247,0.12); color: #a855f7; }
.cf-icon-box.score-icon { background: rgba(13,110,253,0.12); color: #0d6efd; }
.cf-arrow { color: var(--ds-text-muted); }
.cf-arrow svg { width: 20px; height: 20px; }
Expand Down Expand Up @@ -96,6 +97,19 @@
border-color: #0d6efd;
}
.cf-input::placeholder { color: var(--ds-text-muted); }
.cf-input:invalid:not(:placeholder-shown) {
border-color: #dc3545;
}
.cf-error {
font-size: 11px;
color: #dc3545;
margin-top: 4px;
display: none;
}
.cf-input:invalid:not(:placeholder-shown) + .cf-error,
.was-validated .cf-input:invalid + .cf-error {
display: block;
}
.cf-hint {
font-size: 11px;
color: var(--ds-text-muted);
Expand Down Expand Up @@ -246,13 +260,15 @@
<div class="cf-title" id="cf-title">{% trans "Connecter SharePoint &agrave; SCORE" %}</div>
<div class="cf-subtitle">{% trans "Configurez les param&egrave;tres de connexion pour indexer automatiquement vos documents dans SCORE." %}</div>

<form method="post">
{% if error %}<div class="alert alert-danger" style="font-size:13px;border-radius:10px;margin-bottom:18px;">{{ error }}</div>{% endif %}
<form method="post" id="create-form" enctype="multipart/form-data" data-turbo="false">
{% csrf_token %}

<!-- Name -->
<div class="cf-group">
<label for="name" class="cf-label">{% trans "Nom du connecteur" %}</label>
<input type="text" name="name" id="name" class="cf-input" placeholder="{% trans "Ex. Documents RH" %}" required>
<div class="cf-error">{% trans "Veuillez saisir un nom pour le connecteur." %}</div>
</div>

<!-- Type selector -->
Expand Down Expand Up @@ -354,6 +370,15 @@
</div>
</div>

<!-- Zip Upload config -->
<div id="config-zipupload" class="connector-config" style="display:none">
<div class="cf-group">
<label class="cf-label">{% trans "Fichier ZIP" %}</label>
<input type="file" name="zip_file" class="cf-input" accept=".zip,application/zip">
<div class="cf-hint">{% trans "S&eacute;lectionnez une archive ZIP contenant vos documents." %}</div>
</div>
</div>

<!-- Security banner -->
<div class="sec-banner-inline">
<div class="sec-inline-row">
Expand Down Expand Up @@ -409,19 +434,22 @@
sharepoint: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>',
confluence: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
elasticsearch: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="10" ry="4"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(120 12 12)"/></svg>',
generic: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
generic: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
zipupload: '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>'
};
var classes = {
sharepoint: 'sharepoint',
confluence: 'confluence',
elasticsearch: 'elasticsearch',
generic: 'generic'
generic: 'generic',
zipupload: 'zipupload'
};
var titles = {
sharepoint: 'Connecter SharePoint \u00e0 SCORE',
confluence: 'Connecter Confluence \u00e0 SCORE',
elasticsearch: 'Connecter Elasticsearch \u00e0 SCORE',
generic: 'Connecter une source \u00e0 SCORE'
generic: 'Connecter une source \u00e0 SCORE',
zipupload: 'Importer un fichier ZIP dans SCORE'
};

var select = document.getElementById('connector_type');
Expand All @@ -434,14 +462,34 @@
iconBox.innerHTML = icons[type] || icons.generic;
/* Title */
titleEl.textContent = titles[type] || titles.generic;
/* Config panels */
document.querySelectorAll('.connector-config').forEach(function(el) { el.style.display = 'none'; });
/* Config panels – hide all, disable their required fields */
document.querySelectorAll('.connector-config').forEach(function(el) {
el.style.display = 'none';
el.querySelectorAll('[required]').forEach(function(inp) { inp.disabled = true; });
});
var target = document.getElementById('config-' + type);
if (target) target.style.display = 'block';
if (target) {
target.style.display = 'block';
target.querySelectorAll('[disabled]').forEach(function(inp) { inp.disabled = false; });
}
}

select.addEventListener('change', function() { update(this.value); });

/* Form validation feedback */
var form = document.getElementById('create-form');
form.addEventListener('submit', function(e) {
if (!form.checkValidity()) {
e.preventDefault();
form.classList.add('was-validated');
var firstInvalid = form.querySelector(':invalid:not(fieldset)');
if (firstInvalid) {
firstInvalid.focus();
firstInvalid.scrollIntoView({behavior: 'smooth', block: 'center'});
}
}
});

/* Init from query param or default */
var params = new URLSearchParams(window.location.search);
var initial = params.get('type');
Expand Down
41 changes: 40 additions & 1 deletion ingestion/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,28 @@ def run(self):
try:
self._process_document(doc_info)
except Exception as e:
logger.error("Error processing document %s: %s", doc_info.get("source_id"), e)
source_id = doc_info.get("source_id", "")
logger.error("Error processing document %s: %s", source_id, e)
self._stats["errors"] += 1
# Record the document with ERROR status
if (
source_id
and not Document.objects.filter(
project=self.project,
connector=self.connector_config,
source_id=source_id,
).exists()
):
Document.objects.create(
tenant=self.tenant,
project=self.project,
connector=self.connector_config,
source_id=source_id,
title=doc_info.get("title", source_id),
source_url=doc_info.get("source_url", ""),
status=Document.Status.ERROR,
error_message=str(e)[:1000],
)

# Update progress
self.job.processed_documents = i + 1 + len(deleted_ids)
Expand Down Expand Up @@ -136,6 +156,25 @@ def _process_document(self, doc_info: dict):
extracted = extract_text(raw_doc.content, raw_doc.content_type)
if not extracted.text:
logger.warning("Empty text extraction for source_id=%s", source_id)
# Record the document with ERROR status so the user can see what happened
existing = Document.objects.filter(
project=self.project,
connector=self.connector_config,
source_id=source_id,
).first()
if not existing:
Document.objects.create(
tenant=self.tenant,
project=self.project,
connector=self.connector_config,
source_id=source_id,
title=raw_doc.title or doc_info.get("title", ""),
source_url=raw_doc.source_url,
doc_type=raw_doc.doc_type or doc_info.get("content_type", ""),
status=Document.Status.ERROR,
error_message="Text extraction returned empty content",
)
self._stats["errors"] += 1
return

# Hash for change detection
Expand Down
Loading